Files
portal/app/routes/web.php
T
Дмитрий 7e1bf8b42d phase2(deal-patch): PATCH /api/deals/{id} + comment-editor в DealDetailDrawer (этап 1/5)
Drawer из read-only становится editable. ActivityLog event пишется на
каждое изменение поля.

Backend (DealController::update):
- PATCH /api/deals/{id} {tenant_id, comment?, manager_id?, status?}.
- Каждое изменённое поле → ActivityLog:
  comment → deal.commented (context.text);
  manager_id → deal.assigned (context.from/to + assigned_at=NOW);
  status → deal.status_changed (context.from/to/source='manual').
- NO-OP не пишется в audit. Manager FK guard + status slug validation.
- RLS + defense-in-depth where(tenant_id) → 404 для чужой сделки.

Pest +10 (DealUpdateTest):
- 422/404 базовые / 404 чужая сделка / comment+audit / manager+audit+
  assigned_at / status+audit / 422 неизвестный slug / 422 чужой manager /
  NO-OP не пишет / комбинированно → 2 audit записи.

Frontend:
- api/deals.ts::updateDeal — PATCH helper c ensureCsrfCookie.
- DealDetailDrawer: новая секция «Комментарий» (только при tenantId).
  v-textarea auto-grow + counter=5000 + Save-btn → updateDeal →
  toast success + reload events (новый deal.commented в timeline).
  На fail → warning toast.

Vitest +3 (DealDetailDrawerApi):
- saveComment вызывает updateDeal + toast + reload events (getDeal x2).
- saveComment reject → commentSaveError + warning toast.
- comment-section не рендерится без tenantId.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 283/283 за 18.13 сек (+3 от 280).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 220/220 за 25.64 сек (+10 от 210, 871 assertion).

Реестр v1.64→v1.65 / CLAUDE.md v1.55→v1.56.

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

107 lines
6.3 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\LeadStatusController;
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::get('/api/deals/{id}', [DealController::class, 'show'])->where('id', '[0-9]+');
Route::post('/api/deals', [DealController::class, 'store']);
Route::post('/api/deals/export', [DealController::class, 'export']);
Route::post('/api/deals/transition', [DealController::class, 'transition']);
Route::patch('/api/deals/{id}', [DealController::class, 'update'])->where('id', '[0-9]+');
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
Route::get('/api/managers', [ManagerController::class, 'index']);
Route::get('/api/projects', [ProjectController::class, 'index']);
Route::get('/api/lead-statuses', [LeadStatusController::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'));