Files
portal/app/routes/web.php
T
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

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

131 lines
7.8 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\InAppNotificationController;
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']);
});
});
// In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth —
// уведомления USER-personal, читать/писать может только сам user.
Route::middleware('auth:sanctum')->prefix('/api/notifications')->group(function () {
Route::get('/', [InAppNotificationController::class, 'index']);
Route::patch('/{id}/read', [InAppNotificationController::class, 'markRead'])->where('id', '[0-9]+');
Route::post('/mark-all-read', [InAppNotificationController::class, 'markAllRead']);
Route::delete('/{id}', [InAppNotificationController::class, 'destroy'])->where('id', '[0-9]+');
});
// 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]+');
Route::delete('/api/deals', [DealController::class, 'destroy']);
Route::post('/api/deals/restore', [DealController::class, 'restore']);
// 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'));