Files
portal/app/routes/web.php
T
Дмитрий 14dc317e2b phase2(admin-incidents): GET /api/admin/incidents + AdminIncidentsView API (этап 4/5)
Чтение incidents_log с фильтрами type/severity/unresolved_only + summary
(open/investigating/rkn_pending/total_unresolved).

Backend (AdminIncidentsController::index):
- ORDER BY started_at DESC. Filters: type, severity, unresolved_only=true.
- Derived: incident_id (INC-YYYY-MMDD-NNNN), status (resolved_at!=null →
  resolved; detected_at!=null → investigating; иначе open),
  affected_tenants_count из BIGINT[] (parsePgArray для '{1,2,3}'),
  rkn_deadline_at = detected_at+24h для data_breach без notification.
- summary: open/investigating/rkn_pending/total_unresolved.

Pest +11 (AdminIncidentsIndexTest):
- пустой / incident_id формат / derive status / filter type+severity /
  unresolved_only / ORDER BY started_at DESC / rkn_deadline +24h для
  data_breach / non-data_breach без deadline / summary.rkn_pending /
  limit+offset.
- Quirk: saas_admin_users.full_name (не first/last) + нет updated_at.

Frontend:
- api/admin.ts::listAdminIncidents — типизированный helper.
- AdminIncidentsView: унифицированный IncidentRow (mock-category ↔
  API-type, mock-title ↔ API-summary). Reactive rowsState+stats default
  = MOCK; loadIncidents() async на onMounted; fetchError + warning
  alert + MOCK fallback; reload-btn. РКН pending chip учитывает оба
  pdn_breach/data_breach.

Vitest +5:
- listAdminIncidents на mount / replace state+stats + rkn_deadline /
  reject → fetchError+alert+fallback / reload-btn x2 / РКН pending chip
  виден для data_breach без notification.

PHPStan baseline регенерирован. cspell-glossary +MMDD.

Регресс:
- Lint+type-check+format passed.
- Vitest 305/305 за 20.59 сек (+5 от 300).
- Vite build 1.05 сек.
- Pint + PHPStan passed.
- Pest 248/248 за 28.02 сек (+11 от 237, 951 assertion).

Реестр v1.67→v1.68 / CLAUDE.md v1.58→v1.59.

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

119 lines
7.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\AdminBillingController;
use App\Http\Controllers\Api\AdminIncidentsController;
use App\Http\Controllers\Api\AdminSystemSettingsController;
use App\Http\Controllers\Api\AdminTenantsController;
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 → Тенанты: lookup для AdminTenantsView. Без auth (saas-admin SSO ⏸ Б-1).
Route::get('/api/admin/tenants', [AdminTenantsController::class, 'index']);
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', [AdminBillingController::class, 'index']);
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
Route::get('/api/admin/incidents', [AdminIncidentsController::class, 'index']);
// 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'));