Final review (🟢 low): SPA-маршрут /import работал через Route::fallback,
но все остальные app-маршруты перечислены явно в Route::view-блоке
(CLAUDE.md документирует явный список как намеренный паттерн — catch-all
перехватывал бы _test/* runtime-роуты Pest). /import добавлен в список
для консистентности и устойчивости.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет
append-only строку balance_transactions(type='topup'). BillingController
+ route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub:
без платёжного шлюза (ЮKassa — post-Б-1).
Sprint 2 Plan C, audit E1 (backend).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.
phpstan-baseline.neon: additive-only entries for new test file
(Pest\PendingCalls\TestCall false-positives — documented project pattern)
and OutboundWebhookSubscriptionFactory method.childReturnType (same
pattern as ProjectFactory/TenantFactory/UserFactory already in baseline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys). phpstan-baseline.neon updated for
Pest PendingCalls false-positives in the new test file; also removes
8 pre-existing stale ignore.unmatched entries (properties now resolved
by existing @mixin IdeHelper* docblocks — confirmed pre-existing via
git stash test before Task 3 changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.
phpstan-baseline.neon updated for Pest PendingCalls false positives
in the new test file (same pattern as all other Feature test files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit A7: the «Оферта» / «Политика» links in the AuthLayout footer were
raw <a href> pointing at unrouted paths -> 404 via the SPA catch-all.
Adds a single DRY LegalDocView served by /legal/:doc(offer|privacy),
rendering an honest «document being finalized» stub (real legal text
needs юр. редактура — реестр K3 / blocker Б-1). Footer links upgraded
to <RouterLink> for SPA navigation. Also refreshes two stale auth-layout
doc-comments left by the /recovery removal (review M1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit A2/A3: RecoveryCodesView (route /recovery) had a TODO no-op
continue handler and 8 hardcoded mock codes. Recon found the page is
orphaned — nothing in the UI navigates to /recovery. The real 2FA
recovery-codes flow lives entirely in Settings -> Безопасность
(TwoFactorCard setup wizard + RecoveryCodesCard regeneration), both
already wired to the real API. Per user decision (2026-05-15) the
orphan is deleted rather than polished.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 audit found inconsistency in routes/web.php SPA-shell list.
Comment (line 188-190) declares «Регистрируем явно, а не catch-all»
for test isolation, but the explicit list missed:
- /reminders, /projects (main views from Plan 5)
- /admin and 7× /admin/* (added in Plans 4 + 5)
These paths worked via Route::fallback (line 211), but that risks
runtime-routes from Pest beforeEach('_test/*') being shadowed by
fallback BEFORE catch-all. Align explicit list with router/index.ts
to honor the documented rationale.
No behavioral change for production (same welcome view returned);
test-suite isolation contract restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec §5.1 supplier-webhook endpoint. SupplierWebhookController tests
переходят с 405 на 8/8 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 3 Phase B. Закрытие audit O-refactor-02:
- O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с
Sprint 3 Phase A DealController split):
* AuthController (343) — core auth: login/register/logout/me +
updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut,
maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource)
* TwoFactorController (217, новый) — 2FA verify + recovery codes use
(login-flow продолжение). Setup/enable/disable остались в
TwoFactorSetupController (с Sprint 1).
* PasswordResetController (146, новый) — forgot/reset password
API endpoints без изменений (только routing — controller@method обновлён в web.php):
- POST /api/auth/2fa/verify → TwoFactorController@verifyTwoFactor
- POST /api/auth/2fa/recovery-use → TwoFactorController@useRecoveryCode
- POST /api/auth/forgot → PasswordResetController@forgotPassword
- POST /api/auth/reset-password → PasswordResetController@resetPassword
Helpers lockoutResponse и userResource дублируются между классами (1:1) — по
принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая
итерация может вынести в trait/base controller, если появится 4-й класс.
Pest: 416/416 PASS + 2 skipped.
Larastan: 0 errors.
Pint: passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 2 Phase A (modernization). Закрытие audit O-stack-01/02/03 + O-perf-07:
- O-stack-01: Pest 4 browser-tests scaffold. testsuite Browser в phpunit.xml,
Pest.php extend(...)->in('Browser'), tests/Browser/SmokeTest.php
(login-flow + deal-create-flow). На Windows native PHP плагин
pest-plugin-browser требует ext-sockets (отсутствует в стандартной
сборке) и при автозагрузке вызывает socket_create_listen() ДО старта
тестов, что ломает весь Pest. Поэтому плагин НЕ в require-dev,
тесты помечены ->skip(...) с инструкцией активации на Linux CI.
Скелет тестов в комментариях — на CI достаточно
composer require pestphp/pest-plugin-browser --dev + npx playwright
install + раскомментировать тела.
- O-stack-02: infection/infection ^0.32.7 в require-dev + app/infection.json
(minMsi=50, source: Http/Models/Services). composer mutation script.
Запуск отдельной задачей в CI (медленный). allow-plugins для
infection/extension-installer.
- O-stack-03: Laravel 13 string-based lazy-loading в routes/web.php —
убран блок use App\Http\Controllers\Api\* (16 импортов), все
ссылки заменены на строки 'App\Http\...\X@method'. Контроллеры
не загружаются автозагрузчиком при boot route'ов; класс резолвится
только при матче маршрута. Использован строковый синтаксис, а не
FQN-class — Pint default preset (fully_qualified_strict_types fixer)
сворачивает FQN-class обратно в use, на строки не действует.
- O-perf-07: Larastan result cache через tmpDir: .phpstan-cache в
phpstan.neon (cache-dir не дублируется флагом — phpstan не принимает
оба источника). lefthook job 6 (larastan) использует этот же
tmpDir автоматически. Ускорение инкрементальных pre-commit прогонов:
на правке одного файла — переанализ только зависящих, не всего
графа классов. .phpstan-cache в .gitignore.
Pest: 416/416 PASS + 2 skipped (browser smoke pending Linux CI).
Larastan: 0 errors above baseline.
Infection: vendor/bin/infection --version → 0.32.7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам:
/api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63).
/api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F.
- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register.
- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars).
- bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для
Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого
бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса
P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs.
Pest: 416/416 PASS.
Larastan: 0 errors.
Pint: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.
Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
окно ±1 день, counts для UI badges);
POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
POST {id}/complete (idempotent);
DELETE {id}.
RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
?? created_by (если active+!deleted). Каналы email+inapp по prefs.
payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
{--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
ставит is_sent=true даже если recipient deactivated (защита от retry-spam).
Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
store + FK guard / update text+remind_at сбрасывает is_sent / complete
idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
skip / уже sent / assignee вместо created_by / deactivated user (is_sent
всё равно) / только inapp при email=false / --dry-run / --limit / RLS.
PHPStan baseline регенерирован. IDE-helper для всех моделей.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает этап 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>
Заменяет static-снапшот LEAD_STATUSES в коде на live-данные из БД.
Custom slug'и (добавленные после deployment'а) теперь видны UI без rebuild'а.
Backend:
- LeadStatus model (PK=slug string, incrementing=false, timestamps=null).
- LeadStatusController::index — GET /api/lead-statuses, ORDER BY sort_order,
slug. Таблица глобальная (не tenant-aware), auth не требуется на MVP.
Pest +5 (LeadStatusesIndexTest):
- 200 + не пустой / все 14 системных slug'ов из seed / все нужные поля /
sort_order ASC / кастомный slug после INSERT появляется в endpoint'е.
Frontend:
- api/leadStatuses.ts::listLeadStatuses — GET helper.
- stores/leadStatuses.ts::useLeadStatusesStore — Pinia setup-store:
statuses default = LEAD_STATUSES snapshot (UI работает без fetch'а),
load(force=false) идемпотентен, bySlug computed Map, findBySlug helper.
На fail — snapshot остаётся, fetchError=true.
- DealsView/KanbanView/DealDetailDrawer переехали со static-импорта
LEAD_STATUSES на store. KanbanView использует safe-access
dealsByStatus[slug] || [] (защита от custom slug'а из API без seeded
column). load() в onMounted у обоих view'ов.
Vitest +7 (leadStatusesStore.spec.ts):
- initial snapshot / findBySlug existing & null / load success replace +
loaded / load reject — fetchError + snapshot fallback / load идемпотентен /
load(force=true) refetch.
- 2 spec'а DealDetailDrawer получили setActivePinia(createPinia()) в
beforeEach (без этого Pinia store-injection в jsdom падает).
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 280/280 за 19.44 сек (+7 от 273).
- Vite build 1.17 сек.
- Pint + PHPStan passed.
- Pest 210/210 за 24.59 сек (+5 от 205, 840 assertions).
Реестр v1.63→v1.64 / CLAUDE.md v1.54→v1.55.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает 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>
3 интеграционных доработки после backend-completion v1.57.
(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
Формат {id, email, first_name, last_name, name, initials} с
formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.
(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.
(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
+ supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
Job + Controller разделяли логику.
Старый тест manager_id=42 переписан под FK guard через User::factory.
PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).
Регресс: lint+type-check+format ✅; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.
Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>