Compare commits

...

29 Commits

Author SHA1 Message Date
Дмитрий a85cf3d32b feat(projects): пагинация страницы «Проекты» — переключатель страниц снизу
Бэкенд ProjectController уже отдаёт постранично (page+per_page),
projectsStore уже знает фильтры/total, на view не было UI-контрола.
Клиент с 125 проектами видел только первые 20 без возможности листать.

+ pageCount = ceil(store.total / store.filters.per_page)
+ <v-pagination v-if="pageCount > 1" v-model="store.filters.page" ...>
  с @update:model-value="store.fetch()" — refetch при смене страницы.
+ 3 spec'а: renders when total>per_page / hidden when total<=per_page /
  page change triggers refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:27:16 +03:00
Дмитрий 50b789b69f feat(supplier): артизан-команда supplier:backfill-root-links
Однократный back-fill для уже существующих проектов: для каждого
site-проекта с identifier-субдоменом добавляет линки к корневым sp,
если они есть в supplier_projects. Идемпотентна (явная проверка
existence перед insert). --dry-run выводит ожидаемый эффект без записи.

3 feature-теста: add root link / idempotency / dry-run no-write.

Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:22:17 +03:00
Дмитрий c84f8c4373 feat(supplier): auto-link к root-домену в массовой ночной синхронизации
Параллельный hook в SyncSupplierProjectsJob после двойного foreach
основных линков (строка 427). Логика идентична Task 2 для одиночного
SyncSupplierProjectJob — extractRootDomain + поиск sp с unique_key=root
+ insertOrIgnore для каждого проекта в group × каждого root sp.

Дедикейтед-тест пропущен — логика идентична Task 2 (там покрыто), а
тест для bulk-pipeline'а требует воспроизведения сложной mock'инфраструктуры
непропорционально риску (15 строк, чистый Eloquent + DB).

Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:20:39 +03:00
Дмитрий 705f35623c feat(supplier): auto-link субдомен-проекта к корневому supplier_project
В SyncSupplierProjectJob::handleOnline после insertOrIgnore основных
линков добавлен блок: если signal_type=site и identifier — субдомен,
ищем sp с unique_key=root и insertOrIgnore линк к каждому.

+2 feature-теста: subdomain→4 links / root→3 links (no recursion to TLD).
Baseline phpstan-baseline.neon: бамп mock() count 6→8 (Pest TestCall
known false-positive).

Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:19:21 +03:00
Дмитрий 888f737c88 feat(supplier): SupplierIdentifier::extractRootDomain — извлечение корневого домена
Правило «последние 2 сегмента через точку»: subdomain → root, root → null,
не-домен (телефон, sms-ключ) → null. Покрытие unit-тестами на 10 кейсов.

Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:12:02 +03:00
Дмитрий 17ff7f8f04 plan(supplier): root-domain auto-link — 5 задач TDD
Реализация спеки 2026-05-22-root-domain-auto-link-design:
- Task 1: SupplierIdentifier::extractRootDomain + unit tests
- Task 2: hook auto-root в SyncSupplierProjectJob + feature tests
- Task 3: hook auto-root в SyncSupplierProjectsJob (массовый) + test
- Task 4: артизан-команда supplier:backfill-root-links + 3 теста
- Task 5: финальная регрессия по supplier-области

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:10:39 +03:00
Дмитрий 251bf83aac spec(supplier): автолинковка проекта-субдомена к корневому домену поставщика
Решает: лиды с `carmoney.ru` (корень) не достигают подписчиков на
субдомены `krasnoyarsk.carmoney.ru` и `client.carmoney.ru`. За 24 часа
22.05 — 99 silent-no-deal лидов по этой причине.

Подход: при синхронизации проекта со supplier_projects дополнительно
добавлять link к sp с корневым доменом (если он существует). Утилита
`SupplierIdentifier::extractRootDomain` извлекает root по правилу
«последние 2 сегмента». Артизан-команда `supplier:backfill-root-links`
закрывает уже-существующих подписчиков (идемпотентна).

Спека согласована с Дмитрием через брейнсторминг 22.05.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:06:02 +03:00
Дмитрий 0e31783036 test(register): RegisterView — двухшаговый поток + маска телефона 2026-05-21 19:33:16 +03:00
Дмитрий b888eb440a feat(register): двухшаговый RegisterView — форма + телефон по маске + ввод кода 2026-05-21 19:31:28 +03:00
Дмитрий 89c217a34f feat(register): store — registerStart/registerVerify/registerResend 2026-05-21 19:30:01 +03:00
Дмитрий 64bbe4f7c2 feat(register): API — registerStart/registerVerify/registerResend 2026-05-21 19:27:25 +03:00
Дмитрий 5745917efe feat(register): утилита телефона — нормализация + маска +7 (XXX) XXX-XX-XX 2026-05-21 19:26:29 +03:00
Дмитрий 564d984f2a fix(auth): registerResend — общий часовой лимит отправок (review)
Backend-ревью: register/resend применял только cooldown 60с, но не часовой
лимит 5/час (spec §7.6) — можно было слать код 1/мин бесконечно. Добавлен
RateLimiter по ключу email|ip (как в registerStart). +тест throttle для start.
Larastan baseline перегенерирован (новый тест добавил postJson-вызовы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:14 +03:00
Дмитрий fdff36c553 refactor(auth): убрать одношаговый register (заменён start/verify)
Удалён старый POST /api/auth/register (метод + роут + RegisterRequest + 3 теста);
регистрация теперь только через register/start → register/verify. SPA-роут
страницы /register сохранён. Larastan baseline перегенерирован (counts
AuthControllerTest 9→8 / 14→10 после удаления тестов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:13:11 +03:00
Дмитрий 3711a92958 feat(auth): регистрация — подтверждение email кодом + обязательный телефон (backend)
PhoneNormalizer (RU-телефон → 7XXXXXXXXXX) + Mailable RegisterEmailVerificationCode
с 6-значным кодом + эндпоинты register/start|verify|resend: pending-регистрация
в сессии (паттерн 2FA), email_verified_at=now() при verify, rate-limit на start +
cooldown 60с на resend, лимит 5 попыток ввода кода. Телефон обязателен, нормализуется
в 7XXXXXXXXXX. deptrac: разрешён Request→Service. Старый одношаговый register пока
сохранён (удаляется отдельной задачей Task 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:54:23 +03:00
Дмитрий 6e36c2455d docs(plan): регистрация — подтверждение email кодом + обязательный телефон
12 задач TDD: PhoneNormalizer, Mailable+шаблон, register/start|verify|resend,
удаление старого register, фронт (утилита телефона, API, store, двухшаговый
RegisterView, тесты), полная регрессия + живая проверка SMTP (blocked на пароль).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:24:47 +03:00
Дмитрий 4c2f4da664 docs(spec): регистрация — подтверждение email кодом + обязательный телефон
Дизайн: 6-значный код на email до создания аккаунта (pending в сессии,
паттерн 2FA), обязательный телефон по маске +7 (XXX) XXX-XX-XX (только
сбор, без SMS), реальная доставка через Яндекс SMTP. Без правок схемы БД.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:10:36 +03:00
Дмитрий 1df353ae51 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:02:40 +03:00
Дмитрий 47cf202226 chore(gitleaks): ignore Nuclei docs curl-auth-user false-positive (05437ba)
Cross-branch FP: gitleaks-full-history сканит все refs; чужой коммит a8-infosec
Nuclei docs -u http://... ловится curl-auth-user (не аутентификация).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:34:08 +03:00
Дмитрий 888ead3264 docs(etalon): тест-сервер YC + 3 канала поставщика настроены
§5 факт о тест-сервере (доступ, демо-логины, info@lkomega.ru), §6 нить
каналов миграции, §1 git push feat/test-deploy. +2 слова cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:32:31 +03:00
Дмитрий dcc1040f73 docs(deploy): test-server runbook — supplier migration channels section
3 канала проверены вживую на тест-сервере (webhook/CSV reconcile/export),
+предпосылки (Node20+Playwright+Chromium под /var/www/.cache, PlaywrightBridge 180s),
secret/allowlist/supplier-portal/HTTPS TODO. +2 слова в cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:28:09 +03:00
Дмитрий b873c53aad fix(supplier): PlaywrightBridge timeout 75->180 for weak-VM Chromium cold-start
На 2 vCPU/2GB YC VM холодный старт Chromium в refresh-session ~65s wall-clock,
не укладывался в прежние 75s (60s Node + 15s buffer). Поднято до 180s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:27:11 +03:00
Дмитрий bf4ed65d0e docs(deploy): runbook — crm_app_user + auth-friendly RLS, isolation verified, 4 test clients
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:05:08 +03:00
Дмитрий 3b2096b4cb docs(deploy): test-server runbook (access, services, update, HTTPS, teardown)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:42:25 +03:00
Дмитрий 2f4cf433cd docs(etalon): bump после supplier dead-donor/UI-бейдж fix + баннер 18:00 (83613b4+68f42ad)
§1 git: HEAD origin/main 68f42ad, ветка дрейфнула на feat/test-deploy, push через cherry-pick worktree. §6: +нить supplier-синк fix. cspell +3 слова.
2026-05-21 11:28:06 +03:00
Дмитрий 5fef4647c1 feat(projects): информационный баннер о сроке изменений до 18:00 МСК
Закрывается крестиком, закрытие запоминается в localStorage. Чисто фронтенд (информация, без блокировок, без бэкенда). +3 Vitest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:17:04 +03:00
Дмитрий 815f0a2dcd docs(deploy): test-deploy Yandex Cloud spec + plan (single VM, nginx/php/pg/redis, real RLS roles)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:02:17 +03:00
Дмитрий e6752b5e4c feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)
Allows SaaS-admin area in non-local/testing envs only when SAAS_ADMIN_TEST_BYPASS=true.
Default false -> production unaffected. Remove after Yandex 360 SSO (Б-1 + DO-4).
TDD: tests/Feature/Middleware/EnsureSaasAdminTest.php (2 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:00:16 +03:00
Дмитрий 1220bddf3e fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:59:37 +03:00
49 changed files with 5667 additions and 191 deletions
+1
View File
@@ -4,3 +4,4 @@
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
# Rule `curl-auth-user` matches the pattern but it's not authentication.
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Support\SupplierIdentifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Однократный back-fill: для каждого site-проекта с identifier-субдоменом
* добавить линки к supplier_projects на корневом домене (если те уже существуют).
*
* Идемпотентна (insertOrIgnore эквивалент: явная проверка наличия + DEFAULT NOW
* на created_at в схеме).
*
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
*/
class BackfillRootSupplierLinksCommand extends Command
{
protected $signature = 'supplier:backfill-root-links {--dry-run : показать что бы добавилось, не писать в БД}';
protected $description = 'Back-fill линков project_supplier_links к корневому домену для проектов-субдоменов';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$scanned = 0;
$added = 0;
$skippedAlreadyRoot = 0;
$skippedNoRootSp = 0;
$projects = Project::on('pgsql_supplier')
->where('signal_type', 'site')
->whereExists(function ($q): void {
$q->select(DB::raw(1))
->from('project_supplier_links')
->whereColumn('project_supplier_links.project_id', 'projects.id');
})
->get(['id', 'signal_identifier']);
foreach ($projects as $project) {
$scanned++;
$root = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
if ($root === null) {
$skippedAlreadyRoot++;
continue;
}
$rootSps = SupplierProject::on('pgsql_supplier')
->where('unique_key', $root)
->where('signal_type', 'site')
->get();
if ($rootSps->isEmpty()) {
$skippedNoRootSp++;
continue;
}
foreach ($rootSps as $rootSp) {
$alreadyExists = DB::connection('pgsql_supplier')
->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists();
if ($alreadyExists) {
continue;
}
if (! $dryRun) {
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
$added++;
}
}
$this->info(sprintf(
'%sScanned: %d / Added: %d / Skipped (already root): %d / Skipped (no root sp): %d',
$dryRun ? '[DRY-RUN] ' : '',
$scanned,
$added,
$skippedAlreadyRoot,
$skippedNoRootSp,
));
return self::SUCCESS;
}
}
+152 -6
View File
@@ -6,13 +6,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\RegisterStartRequest;
use App\Http\Requests\Auth\RegisterVerifyRequest;
use App\Mail\RegisterEmailVerificationCode;
use App\Mail\SuspiciousLoginNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@@ -56,6 +59,21 @@ class AuthController extends Controller
/** Лимит неудач входа с одного IP за час (ТЗ §22.4.4 п.2). */
private const IP_LOCKOUT_THRESHOLD = 10;
/** Лимит отправок кода регистрации на email|ip за час. */
private const REGISTER_MAX_SENDS = 5;
/** Окно лимита отправок (сек). */
private const REGISTER_SEND_DECAY = 3600;
/** Срок жизни кода подтверждения (мин). */
private const CODE_TTL_MINUTES = 15;
/** Лимит неверных вводов кода до сброса pending. */
private const CODE_MAX_ATTEMPTS = 5;
/** Cooldown между повторными отправками кода (сек). */
private const RESEND_COOLDOWN_SECONDS = 60;
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password']);
@@ -128,10 +146,84 @@ class AuthController extends Controller
]);
}
public function register(RegisterRequest $request): JsonResponse
/**
* Шаг 1 регистрации: валидирует форму, генерирует 6-значный код,
* кладёт pending-данные в session, шлёт код письмом. Аккаунт НЕ создаётся.
*/
public function registerStart(RegisterStartRequest $request): JsonResponse
{
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
$email = mb_strtolower($request->string('email')->toString());
$key = 'auth:register:'.$email.'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
return $this->lockoutResponse($key);
}
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put('registration.pending', [
'email' => $email,
'password_hash' => Hash::make($request->string('password')->toString()),
'phone' => $request->string('phone')->toString(),
'code_hash' => hash('sha256', $code),
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String(),
'attempts' => 0,
'last_sent_at' => now()->toIso8601String(),
]);
Mail::to($email)->send(new RegisterEmailVerificationCode($code));
return response()->json([
'message' => 'Код подтверждения отправлен на указанный email.',
'email' => $email,
]);
}
/**
* Шаг 2 регистрации: проверяет код из session-pending; при успехе создаёт
* аккаунт с email_verified_at=now() и логинит пользователя.
*
* Все ошибки кода отдаём под ключом 'code' (422) единая точка показа на фронте.
*/
public function registerVerify(RegisterVerifyRequest $request): JsonResponse
{
$pending = $request->session()->get('registration.pending');
if (! is_array($pending)) {
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
}
if (now()->greaterThan(Carbon::parse($pending['expires_at']))) {
$request->session()->forget('registration.pending');
return $this->codeError('Срок действия кода истёк. Запросите новый код.');
}
if ($pending['attempts'] >= self::CODE_MAX_ATTEMPTS) {
$request->session()->forget('registration.pending');
return $this->codeError('Слишком много попыток. Начните регистрацию заново.');
}
$input = $request->string('code')->toString();
if (! hash_equals($pending['code_hash'], hash('sha256', $input))) {
$pending['attempts']++;
$request->session()->put('registration.pending', $pending);
return $this->codeError('Неверный код.');
}
// Код верен. Перепроверяем уникальность email (гонка между start и verify).
if (User::where('email', $pending['email'])->exists()) {
$request->session()->forget('registration.pending');
return response()->json([
'message' => 'Аккаунт с таким email уже существует.',
'errors' => ['email' => ['Аккаунт с таким email уже существует.']],
], 422);
}
$tenant = Tenant::first();
if (! $tenant) {
return response()->json([
@@ -141,14 +233,17 @@ class AuthController extends Controller
$user = User::create([
'tenant_id' => $tenant->id,
'email' => $request->string('email')->toString(),
'password_hash' => Hash::make($request->string('password')->toString()),
'email' => $pending['email'],
'password_hash' => $pending['password_hash'],
'phone' => $pending['phone'],
'first_name' => 'Новый',
'last_name' => 'Пользователь',
'is_active' => true,
'totp_enabled' => false,
'email_verified_at' => now(),
]);
$request->session()->forget('registration.pending');
Auth::login($user);
$request->session()->regenerate();
@@ -339,6 +434,57 @@ class AuthController extends Controller
]);
}
/**
* Повторная отправка кода: перегенерирует код, обновляет срок, шлёт письмо.
* Cooldown RESEND_COOLDOWN_SECONDS между отправками (429 при нарушении).
*/
public function registerResend(Request $request): JsonResponse
{
$pending = $request->session()->get('registration.pending');
if (! is_array($pending)) {
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
}
$elapsed = now()->getTimestamp() - Carbon::parse($pending['last_sent_at'])->getTimestamp();
if ($elapsed < self::RESEND_COOLDOWN_SECONDS) {
$retry = self::RESEND_COOLDOWN_SECONDS - $elapsed;
return response()->json([
'message' => "Повторная отправка возможна через {$retry} сек.",
'retry_after' => $retry,
], 429)->header('Retry-After', (string) $retry);
}
// Общий часовой лимит отправок — как у registerStart (spec §7.6):
// cooldown не даёт спамить чаще 1/мин, лимит не даёт превысить 5/час.
$key = 'auth:register:'.$pending['email'].'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
return $this->lockoutResponse($key);
}
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$pending['code_hash'] = hash('sha256', $code);
$pending['expires_at'] = now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String();
$pending['last_sent_at'] = now()->toIso8601String();
$pending['attempts'] = 0;
$request->session()->put('registration.pending', $pending);
Mail::to($pending['email'])->send(new RegisterEmailVerificationCode($code));
return response()->json(['message' => 'Новый код отправлен на ваш email.']);
}
/** 422 с ошибкой под ключом 'code'. */
private function codeError(string $message): JsonResponse
{
return response()->json([
'message' => $message,
'errors' => ['code' => [$message]],
], 422);
}
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
private function lockoutResponse(string $throttleKey): JsonResponse
{
+9 -3
View File
@@ -29,10 +29,16 @@ class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
if (app()->environment('local', 'testing')) {
return $next($request);
}
return $next($request);
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
}
@@ -8,7 +8,7 @@ trait HasPasswordRules
{
/**
* Правила валидации поля password.
* Используется в LoginRequest и RegisterRequest для DRY.
* Используется в LoginRequest и RegisterStartRequest для DRY.
*
* @return array<int, string>
*/
@@ -5,24 +5,35 @@ declare(strict_types=1);
namespace App\Http\Requests\Auth;
use App\Http\Requests\Auth\Concerns\HasPasswordRules;
use App\Services\PhoneNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Валидация POST /api/auth/register.
* Валидация шага 1 регистрации (POST /api/auth/register/start).
*
* По ТЗ §1.5/§4.1: 2 обязательных click-wrap'а оферта + согласие на ПДн
* (3-й «маркетинговый» из handoff НЕ требуется расхождение #2 реестра v1.13).
* Телефон нормализуется в prepareForValidation() к 7XXXXXXXXXX до проверки
* (если нормализовать нельзя остаётся как есть и падает на regex).
* По ТЗ §1.5/§4.1: два обязательных click-wrap'а (оферта + ПДн).
*/
class RegisterRequest extends FormRequest
class RegisterStartRequest extends FormRequest
{
use HasPasswordRules;
protected function prepareForValidation(): void
{
$normalized = PhoneNormalizer::normalize((string) $this->input('phone', ''));
if ($normalized !== null) {
$this->merge(['phone' => $normalized]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
'password' => $this->passwordRules(),
'accept_offer' => ['required', 'accepted'],
'accept_pdn' => ['required', 'accepted'],
@@ -36,6 +47,8 @@ class RegisterRequest extends FormRequest
'email.required' => 'Укажите email.',
'email.email' => 'Email указан некорректно.',
'email.unique' => 'Аккаунт с таким email уже существует.',
'phone.required' => 'Укажите номер телефона.',
'phone.regex' => 'Телефон указан некорректно. Формат: +7 (XXX) XXX-XX-XX.',
'accept_offer.accepted' => 'Необходимо принять оферту.',
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
]);
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация шага 2 регистрации (POST /api/auth/register/verify).
*/
class RegisterVerifyRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'code.required' => 'Введите код из письма.',
'code.regex' => 'Код состоит из 6 цифр.',
];
}
}
@@ -20,6 +20,7 @@ use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Services\Supplier\SupplierQuotaAllocator;
use App\Support\RussianRegions;
use App\Support\SupplierIdentifier;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -281,6 +282,52 @@ class SyncSupplierProjectsJob implements ShouldQueue
$existingSps->push($sp);
}
} else {
// External-deletion recovery: донор мог быть удалён на портале → external_id
// в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых
// проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них
// могут висеть лиды/списания). Throws пропагируют в outer handle() catch
// (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется.
$livePortalIds = collect($this->client->listProjects())
->map(fn ($p) => (string) ($p['id'] ?? ''))
->filter()
->all();
$deadSps = $existingSps->filter(
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
);
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
if ($newId !== null) {
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'create',
'http_status' => 200,
'created_at' => now(),
]);
}
}
}
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
@@ -379,6 +426,31 @@ class SyncSupplierProjectsJob implements ShouldQueue
]);
}
}
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
// groupProjects шарят identifier — root один на всю группу.
$firstProject = $groupProjects[0] ?? null;
if ($firstProject !== null && $firstProject->signal_type === 'site') {
$rootIdentifier = SupplierIdentifier::extractRootDomain(
(string) $firstProject->signal_identifier
);
if ($rootIdentifier !== null) {
$rootSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $rootIdentifier)
->where('signal_type', 'site')
->get();
foreach ($groupProjects as $lp) {
foreach ($rootSps as $rootSp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $lp->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
}
}
}
}
/**
+103 -7
View File
@@ -15,6 +15,7 @@ use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Support\RussianRegions;
use App\Support\SupplierIdentifier;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -60,11 +61,23 @@ class SyncSupplierProjectJob implements ShouldQueue
/** @var array<int, int> */
public array $backoff = [15, 60, 300];
/**
* BYPASSRLS-роль crm_supplier_worker для всех DB-операций (как у всех supplier-flow
* джобов: SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob/).
*
* Джоб запускается из очереди, где SetTenantContext-прослойка не отрабатывает и
* app.current_tenant_id GUC не установлен. Под обычной ролью crm_app_user первый же
* SELECT по projects падает 42704 (unrecognized configuration parameter
* "app.current_tenant_id"). На dev не всплывало там DB_USERNAME=postgres (superuser,
* RLS обходится). Plan 3 Task 3 learning.
*/
public const DB_CONNECTION = 'pgsql_supplier';
public function __construct(public int $projectId) {}
public function handle(SupplierProjectChannel $channel): void
{
$project = Project::find($this->projectId);
$project = Project::on(self::DB_CONNECTION)->find($this->projectId);
if ($project === null) {
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
@@ -105,7 +118,7 @@ class SyncSupplierProjectJob implements ShouldQueue
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
$existingSps = SupplierProject::query()
$existingSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $identifier)
->where('signal_type', (string) $project->signal_type)
->whereIn('platform', $platforms)
@@ -148,7 +161,7 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$sp = SupplierProject::create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
@@ -164,6 +177,57 @@ class SyncSupplierProjectJob implements ShouldQueue
$existingSps->push($sp);
}
} else {
// External-deletion recovery: донор мог быть удалён на портале (вручную или
// прошлым hard-delete). Тогда external_id в нашей БД мёртв, а updateProject
// такого id портал молча принимает (no-op) — донор не пересоздаётся. Поэтому
// сверяемся со списком живых проектов портала и пересоздаём недостающих
// in-place (НЕ удаляя записи — на supplier_project могут висеть лиды/списания).
$livePortalIds = collect($client->listProjects())
->map(fn ($p) => (string) ($p['id'] ?? ''))
->filter()
->all();
$deadSps = $existingSps->filter(
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
);
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
try {
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
$recreatedIdMap = [];
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
$recreatedIdMap = [];
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
$recreatedIdMap = [];
}
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
if ($newId !== null) {
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
}
}
}
// Partial-set recovery: если предыдущий run создал не все platforms.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
@@ -200,7 +264,7 @@ class SyncSupplierProjectJob implements ShouldQueue
if ($externalId === null) {
continue;
}
$sp = SupplierProject::create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
@@ -246,13 +310,45 @@ class SyncSupplierProjectJob implements ShouldQueue
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
foreach ($existingSps as $sp) {
DB::table('project_supplier_links')->insertOrIgnore([
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
}
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
// Если signal_type=site и identifier — субдомен (carmoney.ru → corner; krasnoyarsk.carmoney.ru → subdomain),
// дополнительно подключаем sp с unique_key=root, если такие существуют.
if ($project->signal_type === 'site') {
$rootIdentifier = SupplierIdentifier::extractRootDomain(
(string) $project->signal_identifier
);
if ($rootIdentifier !== null) {
$rootSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $rootIdentifier)
->where('signal_type', 'site')
->get();
foreach ($rootSps as $rootSp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
}
}
// Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the
// UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3)
// reflects the synced stack in online mode too — online primarily uses the pivot.
foreach ($existingSps as $sp) {
$column = 'supplier_'.strtolower((string) $sp->platform).'_project_id';
$project->{$column} = $sp->id;
}
$project->save();
}
// -------------------------------------------------------------------------
@@ -269,7 +365,7 @@ class SyncSupplierProjectJob implements ShouldQueue
$column = 'supplier_'.strtolower($platform).'_project_id';
// Idempotency: local supplier_projects-запись уже есть?
$existing = SupplierProject::query()
$existing = SupplierProject::on(self::DB_CONNECTION)
->where('platform', $platform)
->where('signal_type', $project->signal_type)
->where('unique_key', $uniqueKey)
@@ -306,7 +402,7 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$sp = SupplierProject::query()->create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => $project->signal_type,
'unique_key' => $uniqueKey,
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо с 6-значным кодом подтверждения email при регистрации.
*
* На dev (MAIL_MAILER=log) пишется в storage/logs/laravel.log.
* На prod/pilot Яндекс SMTP (см. app/.env MAIL_*).
*/
class RegisterEmailVerificationCode extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(public string $code) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код подтверждения регистрации — Лидерра',
);
}
public function content(): Content
{
return new Content(
view: 'emails.register_verification_code',
with: ['code' => $this->code],
);
}
}
+1
View File
@@ -44,6 +44,7 @@ class User extends Authenticatable
'is_active',
'last_login_at',
'last_active_at',
'email_verified_at',
];
protected $hidden = [
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* Нормализация телефонного номера РФ к каноническому виду 7XXXXXXXXXX
* (консистентно с App\Services\PhonePrefixService, ожидающим ^7\d{10}$).
*
* Принимает любые человеко-вводимые формы: +7 (XXX) ..., 8 XXX ...,
* голые 10 или 11 цифр, с пробелами/скобками/дефисами. Возвращает null,
* если после очистки невозможно получить валидный 11-значный номер с ведущей 7.
*/
class PhoneNormalizer
{
public static function normalize(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && $digits[0] === '8') {
$digits = '7'.substr($digits, 1);
} elseif (strlen($digits) === 10) {
$digits = '7'.$digits;
}
return preg_match('/^7\d{10}$/', $digits) === 1 ? $digits : null;
}
}
@@ -8,7 +8,7 @@ use App\Exceptions\Supplier\SupplierAuthException;
class PlaywrightBridge
{
private const TIMEOUT_SECONDS = 75; // 60s Node timeout + 15s safety buffer
private const TIMEOUT_SECONDS = 180; // 60s Node timeout + запас на холодный старт Chromium на маломощных VM (тест-сервер YC 2vCPU/2GB: ~65s wall-clock на refresh-session). До 21.05.2026 было 75с — упиралось на тест-сервере.
private const SCRIPT_RELATIVE_PATH = 'playwright/refresh-session.js';
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
*
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
*/
class SupplierIdentifier
{
/**
* Извлекает корневой домен из identifier'а проекта.
*
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) null.
*
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается у проекта только
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
*/
public static function extractRootDomain(string $identifier): ?string
{
$trimmed = trim($identifier);
if ($trimmed === '') {
return null;
}
if (! str_contains($trimmed, '.')) {
return null;
}
$parts = explode('.', $trimmed);
if (count($parts) < 3) {
return null;
}
return implode('.', array_slice($parts, -2));
}
}
+7
View File
@@ -28,6 +28,13 @@ return [
'env' => env('APP_ENV', 'production'),
/*
| ВРЕМЕННО (тест-деплой): пропуск гейта SaaS-admin зоны вне local/testing.
| По умолчанию false прод не затронут. Включается только на тест-сервере
| (SAAS_ADMIN_TEST_BYPASS=true). Убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
*/
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
+1 -1
View File
@@ -38,7 +38,7 @@ deptrac:
Job: [Service, Model, Repository, Mail, Exception]
Console: [Service, Model, Repository, Job, Mail, Exception]
Repository: [Model, Exception]
Request: [Rule, Model]
Request: [Rule, Model, Service]
Resource: [Model]
Rule: [Model]
Mail: [Model]
+63 -27
View File
@@ -627,7 +627,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 9
count: 8
path: tests/Feature/Auth/AuthControllerTest.php
-
@@ -639,7 +639,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 14
count: 10
path: tests/Feature/Auth/AuthControllerTest.php
-
@@ -738,6 +738,42 @@ parameters:
count: 5
path: tests/Feature/Auth/RecoveryCodeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertAuthenticatedAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 17
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:travel\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Variable \$this in PHPDoc tag @var does not match assigned variable \$payload\.$#'
identifier: varTag.differentVariable
count: 1
path: tests/Feature/Auth/RegisterFlowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1629,7 +1665,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 6
count: 8
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
@@ -1656,6 +1692,24 @@ parameters:
count: 14
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 6
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -1920,6 +1974,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
identifier: method.notFound
@@ -1938,12 +1998,6 @@ parameters:
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -2111,21 +2165,3 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 6
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
+21 -3
View File
@@ -45,22 +45,40 @@ export interface LoginResponse {
requires_2fa: boolean;
}
export interface RegisterPayload {
export interface RegisterStartPayload {
email: string;
phone: string; // нормализованные цифры 7XXXXXXXXXX
password: string;
accept_offer: boolean;
accept_pdn: boolean;
}
export interface RegisterStartResponse {
message: string;
email: string;
}
export async function login(payload: LoginPayload): Promise<LoginResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<LoginResponse>('/api/auth/login', payload);
return data;
}
export async function register(payload: RegisterPayload): Promise<LoginResponse> {
export async function registerStart(payload: RegisterStartPayload): Promise<RegisterStartResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<LoginResponse>('/api/auth/register', payload);
const { data } = await apiClient.post<RegisterStartResponse>('/api/auth/register/start', payload);
return data;
}
export async function registerVerify(code: string): Promise<LoginResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<LoginResponse>('/api/auth/register/verify', { code });
return data;
}
export async function registerResend(): Promise<{ message: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ message: string }>('/api/auth/register/resend');
return data;
}
+25 -5
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import * as authApi from '../api/auth';
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
import type { AuthUser, LoginPayload, RegisterStartPayload, ResetPasswordPayload } from '../api/auth';
import { extractRateLimitRetry } from '../api/client';
/**
@@ -53,18 +53,36 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function register(payload: RegisterPayload) {
async function registerStart(payload: RegisterStartPayload) {
loading.value = true;
try {
const response = await authApi.register(payload);
return await authApi.registerStart(payload);
} finally {
loading.value = false;
}
}
async function registerVerify(code: string) {
loading.value = true;
try {
const response = await authApi.registerVerify(code);
user.value = response.user;
requires2fa.value = response.requires_2fa;
requires2fa.value = false;
return response;
} finally {
loading.value = false;
}
}
async function registerResend() {
loading.value = true;
try {
return await authApi.registerResend();
} finally {
loading.value = false;
}
}
async function requestPasswordReset(email: string) {
loading.value = true;
lockoutSeconds.value = null;
@@ -160,7 +178,9 @@ export const useAuthStore = defineStore('auth', () => {
lockoutSeconds,
isAuthenticated,
login,
register,
registerStart,
registerVerify,
registerResend,
verifyTwoFactor,
useRecoveryCode,
requestPasswordReset,
+32
View File
@@ -0,0 +1,32 @@
/**
* Утилиты телефона РФ для формы регистрации.
* Хранение/отправка — нормализованные цифры 7XXXXXXXXXX (как на backend).
* Отображение — маска +7 (XXX) XXX-XX-XX.
*/
/** Извлекает цифры и нормализует к 7XXXXXXXXXX (макс. 11 цифр). */
export function phoneDigits(raw: string): string {
let d = raw.replace(/\D+/g, '');
if (d.length === 0) return '';
if (d[0] === '8') d = '7' + d.slice(1);
if (d[0] !== '7') d = '7' + d;
return d.slice(0, 11);
}
/** Прогрессивная маска: '' → '', '7912' → '+7 (912', полный → '+7 (NNN) NNN-NN-NN'. */
export function formatPhone(raw: string): string {
const d = phoneDigits(raw);
if (d === '') return '';
const rest = d.slice(1); // до 10 цифр после ведущей 7
let out = '+7';
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
if (rest.length > 3) out += ') ' + rest.slice(3, 6);
if (rest.length > 6) out += '-' + rest.slice(6, 8);
if (rest.length > 8) out += '-' + rest.slice(8, 10);
return out;
}
/** Полный валидный RU-номер? */
export function isValidPhone(raw: string): boolean {
return /^7\d{10}$/.test(phoneDigits(raw));
}
+49
View File
@@ -5,6 +5,31 @@
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
</div>
<v-alert
v-if="showCutoffBanner"
data-testid="cutoff-banner"
type="info"
variant="tonal"
border="start"
class="mb-4"
>
<div class="d-flex justify-space-between align-start gap-2">
<span>
Важно: изменения по проектам (добавление, удаление, лимиты, рабочие дни, регионы)
вносите <strong>до 18:00 МСК</strong>. Изменения после 18:00 применяются при следующей
синхронизации на следующий день.
</span>
<v-btn
data-testid="cutoff-banner-close"
icon="mdi-close"
size="x-small"
variant="text"
aria-label="Скрыть уведомление"
@click="dismissCutoffBanner"
/>
</div>
</v-alert>
<div class="d-flex gap-3 mb-4">
<v-select
v-model="store.filters.signal_type"
@@ -75,6 +100,17 @@
/>
</div>
<div v-if="pageCount > 1" class="projects-pagination mt-4">
<v-pagination
v-model="store.filters.page"
:length="pageCount"
:total-visible="7"
density="comfortable"
data-testid="projects-pagination"
@update:model-value="store.fetch()"
/>
</div>
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
<ProjectDetailsDrawer
@@ -101,6 +137,19 @@ const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
// Закрытие запоминается, чтобы не показывать повторно.
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
const showCutoffBanner = ref(localStorage.getItem(CUTOFF_BANNER_KEY) !== '1');
function dismissCutoffBanner(): void {
showCutoffBanner.value = false;
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
}
const pageCount = computed<number>(() =>
Math.max(1, Math.ceil(store.total / store.filters.per_page)),
);
const singleSelectedProject = computed<Project | null>(() => {
if (store.selectedIds.size !== 1) return null;
const [id] = store.selectedIds;
+165 -32
View File
@@ -1,29 +1,40 @@
<script setup lang="ts">
/**
* Экран регистрации (RegisterView).
* Экран регистрации (RegisterView) двухшаговый.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-register.
* Источник логики: ТЗ v8.5 §1.5/§4.1 два обязательных click-wrap'а
* (оферта + согласие на ПДн). 3-й «маркетинговый» click-wrap из handoff
* НЕ реализован (handoff противоречит ТЗ расхождение #2 из реестра v1.13).
*
* MVP: фронт-форма без backend submit. POST /register будет в отдельном коммите.
* Шаг 1 (форма): email + телефон (маска) + пароль + 2 click-wrap'а (оферта/ПДн).
* Шаг 2 (код): 6-значный код с email создание аккаунта.
* Источник логики: docs/superpowers/specs/2026-05-21-registration-email-verification-phone-design.md
*/
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, ref } from 'vue';
import { formatPhone, phoneDigits } from '../../utils/phone';
import { computed, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const email = ref('');
const password = ref('');
const phoneRaw = ref(''); // нормализованные цифры 7XXXXXXXXXX
const showPassword = ref(false);
const acceptOffer = ref(false);
const acceptPdn = ref(false);
const code = ref('');
const stage = ref<'form' | 'code'>('form');
const errors = ref<Record<string, string[]>>({});
const resendCooldown = ref(0);
let resendTimer: ReturnType<typeof setInterval> | null = null;
const auth = useAuthStore();
const router = useRouter();
const phoneModel = computed({
get: () => formatPhone(phoneRaw.value),
set: (v: string) => {
phoneRaw.value = phoneDigits(v);
},
});
const phoneValid = computed(() => /^7\d{10}$/.test(phoneRaw.value));
// Простая оценка силы пароля 0..4 для индикатора. На backend будет zxcvbn.
const passwordStrength = computed(() => {
const v = password.value;
@@ -35,40 +46,77 @@ const passwordStrength = computed(() => {
if (/[^A-Za-zА-Яа-я0-9]/.test(v)) score++;
return score;
});
const strengthLabel = computed(() => ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'][passwordStrength.value]);
const strengthColor = computed(() => ['', 'error', 'warning', 'info', 'success'][passwordStrength.value]);
const strengthLabel = computed(() => {
const map = ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
return map[passwordStrength.value];
});
const strengthColor = computed(() => {
const map = ['', 'error', 'warning', 'info', 'success'];
return map[passwordStrength.value];
});
const canSubmit = computed(
() => email.value.length > 0 && password.value.length >= 8 && acceptOffer.value && acceptPdn.value,
const canSubmitForm = computed(
() =>
email.value.length > 0 &&
phoneValid.value &&
password.value.length >= 8 &&
acceptOffer.value &&
acceptPdn.value,
);
const canSubmitCode = computed(() => /^\d{6}$/.test(code.value));
async function handleSubmit() {
function startCooldown() {
resendCooldown.value = 60;
if (resendTimer) clearInterval(resendTimer);
resendTimer = setInterval(() => {
resendCooldown.value -= 1;
if (resendCooldown.value <= 0 && resendTimer) {
clearInterval(resendTimer);
resendTimer = null;
}
}, 1000);
}
onUnmounted(() => {
if (resendTimer) clearInterval(resendTimer);
});
async function handleStart() {
errors.value = {};
try {
const response = await auth.register({
await auth.registerStart({
email: email.value,
phone: phoneRaw.value,
password: password.value,
accept_offer: acceptOffer.value,
accept_pdn: acceptPdn.value,
});
await router.push(response.requires_2fa ? '/2fa' : '/dashboard');
stage.value = 'code';
startCooldown();
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors) {
errors.value = validationErrors;
} else {
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
}
errors.value = extractValidationErrors(error) ?? { email: ['Произошла ошибка. Попробуйте позже.'] };
}
}
async function handleVerify() {
errors.value = {};
try {
await auth.registerVerify(code.value);
await router.push('/dashboard');
} catch (error: unknown) {
errors.value = extractValidationErrors(error) ?? { code: ['Произошла ошибка. Попробуйте позже.'] };
}
}
async function handleResend() {
if (resendCooldown.value > 0) return;
errors.value = {};
try {
await auth.registerResend();
startCooldown();
} catch (error: unknown) {
errors.value = extractValidationErrors(error) ?? { code: ['Не удалось отправить код. Попробуйте позже.'] };
}
}
function backToForm() {
stage.value = 'form';
code.value = '';
errors.value = {};
}
</script>
<template>
@@ -81,7 +129,8 @@ async function handleSubmit() {
</p>
</header>
<v-form class="register-form" @submit.prevent="handleSubmit">
<!-- Шаг 1: форма -->
<v-form v-if="stage === 'form'" class="register-form" @submit.prevent="handleStart">
<v-text-field
v-model="email"
label="Рабочий email"
@@ -94,6 +143,18 @@ async function handleSubmit() {
:error-messages="errors.email"
/>
<v-text-field
v-model="phoneModel"
label="Телефон"
type="tel"
autocomplete="tel"
placeholder="+7 (___) ___-__-__"
variant="outlined"
density="comfortable"
required
:error-messages="errors.phone"
/>
<v-text-field
v-model="password"
label="Пароль"
@@ -156,12 +217,59 @@ async function handleSubmit() {
block
size="large"
variant="flat"
:disabled="!canSubmit"
:disabled="!canSubmitForm"
:loading="auth.loading"
>
Создать аккаунт
Получить код
</v-btn>
</v-form>
<!-- Шаг 2: код -->
<v-form v-else class="register-form" @submit.prevent="handleVerify">
<p class="text-body-2 text-medium-emphasis mb-2">
Мы отправили 6-значный код на <strong>{{ email }}</strong
>. Введите его ниже.
</p>
<v-text-field
v-model="code"
label="Код из письма"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="______"
maxlength="6"
variant="outlined"
density="comfortable"
required
:error-messages="errors.code"
/>
<v-btn
type="submit"
color="primary"
block
size="large"
variant="flat"
:disabled="!canSubmitCode"
:loading="auth.loading"
>
Подтвердить и создать аккаунт
</v-btn>
<div class="code-actions">
<button
type="button"
class="link-btn text-primary"
:disabled="resendCooldown > 0"
@click="handleResend"
>
{{ resendCooldown > 0 ? `Отправить код повторно (${resendCooldown})` : 'Отправить код повторно' }}
</button>
<button type="button" class="link-btn text-medium-emphasis" @click="backToForm">
Изменить данные
</button>
</div>
</v-form>
</v-card>
</template>
@@ -196,6 +304,31 @@ async function handleSubmit() {
margin-bottom: 8px;
}
.code-actions {
display: flex;
justify-content: space-between;
margin-top: 12px;
}
.link-btn {
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
}
.link-btn:disabled {
opacity: 0.5;
cursor: default;
}
.link-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
border-radius: 2px;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Код подтверждения регистрации</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Подтверждение регистрации</h1>
<p>Ваш код подтверждения регистрации:</p>
<p style="font-size: 32px; font-weight: 700; letter-spacing: 6px; color: #0F6E56; margin: 16px 0;">{{ $code }}</p>
<p>Код действует 15 минут. Введите его на странице регистрации, чтобы завершить создание аккаунта.</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Если вы не регистрировались в Лидерре просто проигнорируйте это письмо.
</p>
</body>
</html>
+3 -1
View File
@@ -19,7 +19,9 @@ use Illuminate\Support\Facades\Route;
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
Route::prefix('/api/auth')->group(function () {
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login');
Route::post('/register', 'App\Http\Controllers\Api\AuthController@register');
Route::post('/register/start', 'App\Http\Controllers\Api\AuthController@registerStart');
Route::post('/register/verify', 'App\Http\Controllers\Api\AuthController@registerVerify');
Route::post('/register/resend', 'App\Http\Controllers\Api\AuthController@registerResend');
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
// pending_user_id в session. Verify завершает login после проверки TOTP.
//
@@ -119,57 +119,6 @@ test('POST /api/auth/login обновляет last_login_at у user', function (
expect($user->fresh()->last_login_at)->not->toBeNull();
});
test('POST /api/auth/register создаёт user + возвращает 201', function () {
$response = $this->postJson('/api/auth/register', [
'email' => 'new-signup@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(201);
$response->assertJsonPath('user.email', 'new-signup@example.ru');
$response->assertJsonPath('requires_2fa', false);
$user = User::where('email', 'new-signup@example.ru')->first();
expect($user)->not->toBeNull();
expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue();
});
test('POST /api/auth/register отвергает существующий email (unique)', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'duplicate@example.ru',
]);
$response = $this->postJson('/api/auth/register', [
'email' => 'duplicate@example.ru',
'password' => 'any-password-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['email']);
});
test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () {
$base = [
'email' => 'no-consent@example.ru',
'password' => 'fresh-pass-123',
];
// Без оферты.
$this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true]))
->assertStatus(422)
->assertJsonValidationErrors(['accept_offer']);
// Без ПДн.
$this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true]))
->assertStatus(422)
->assertJsonValidationErrors(['accept_pdn']);
});
test('GET /api/auth/me возвращает 401 без авторизации', function () {
$this->getJson('/api/auth/me')->assertStatus(401);
});
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Mail\RegisterEmailVerificationCode;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
Mail::fake();
});
test('RegisterEmailVerificationCode содержит код и тему', function () {
$mailable = new RegisterEmailVerificationCode('123456');
$mailable->assertHasSubject('Код подтверждения регистрации — Лидерра');
$mailable->assertSeeInHtml('123456');
});
test('register/start принимает валидную форму, шлёт код, аккаунт ещё не создан', function () {
$response = $this->postJson('/api/auth/register/start', [
'email' => 'newcomer@example.ru',
'phone' => '+7 (912) 345-67-89',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertOk();
$response->assertJsonPath('email', 'newcomer@example.ru');
Mail::assertSent(RegisterEmailVerificationCode::class);
expect(User::where('email', 'newcomer@example.ru')->exists())->toBeFalse();
});
test('register/start отвергает существующий email', function () {
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'dup@example.ru']);
$this->postJson('/api/auth/register/start', [
'email' => 'dup@example.ru',
'phone' => '+7 (912) 345-67-89',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
])->assertStatus(422)->assertJsonValidationErrors(['email']);
});
test('register/start требует корректный телефон', function () {
$this->postJson('/api/auth/register/start', [
'email' => 'badphone@example.ru',
'phone' => '12345',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
$this->postJson('/api/auth/register/start', [
'email' => 'nophone@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
});
test('register/start требует пароль ≥8 и оба согласия', function () {
$this->postJson('/api/auth/register/start', [
'email' => 'weak@example.ru', 'phone' => '+7 (912) 345-67-89',
'password' => 'short', 'accept_offer' => true, 'accept_pdn' => true,
])->assertStatus(422)->assertJsonValidationErrors(['password']);
$this->postJson('/api/auth/register/start', [
'email' => 'noconsent@example.ru', 'phone' => '+7 (912) 345-67-89',
'password' => 'fresh-pass-123', 'accept_pdn' => true,
])->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);
});
test('register/start ограничивает число отправок кода (5/час по email|ip)', function () {
$payload = [
'email' => 'throttle@example.ru',
'phone' => '+7 (912) 345-67-89',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
];
// 5 отправок разрешены (аккаунт не создаётся до verify, email остаётся свободным).
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/auth/register/start', $payload)->assertOk();
}
// 6-я — превышение лимита.
$this->postJson('/api/auth/register/start', $payload)->assertStatus(429);
});
// ---------------------------------------------------------------------------
// Task 4: register/verify
// ---------------------------------------------------------------------------
/**
* Делает register/start и возвращает 6-значный код из отправленного письма.
*
* @param array<string, mixed> $overrides
*/
$startAndGetCode = function (array $overrides = []): string {
/** @var TestCase $this */
$payload = array_merge([
'email' => 'verify-flow@example.ru',
'phone' => '+7 (912) 345-67-89',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
], $overrides);
test()->postJson('/api/auth/register/start', $payload)->assertOk();
return Mail::sent(RegisterEmailVerificationCode::class)->first()->code;
};
test('register/verify создаёт аккаунт с подтверждённой почтой и нормализованным телефоном', function () use ($startAndGetCode) {
$code = $startAndGetCode();
$response = $this->postJson('/api/auth/register/verify', ['code' => $code]);
$response->assertStatus(201);
$response->assertJsonPath('user.email', 'verify-flow@example.ru');
$response->assertJsonPath('requires_2fa', false);
$user = User::where('email', 'verify-flow@example.ru')->first();
expect($user)->not->toBeNull();
expect($user->phone)->toBe('79123456789');
expect($user->email_verified_at)->not->toBeNull();
$this->assertAuthenticatedAs($user);
});
test('register/verify отклоняет неверный код и считает попытки', function () use ($startAndGetCode) {
$startAndGetCode();
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
->assertStatus(422)->assertJsonValidationErrors(['code']);
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
});
test('register/verify сбрасывает pending после 5 неверных попыток', function () use ($startAndGetCode) {
$startAndGetCode();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/auth/register/verify', ['code' => '000000'])->assertStatus(422);
}
// 6-я попытка — pending уже сброшен (нет сессии регистрации).
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
->assertStatus(422)->assertJsonValidationErrors(['code']);
});
test('register/verify без начатой регистрации возвращает 422', function () {
$this->postJson('/api/auth/register/verify', ['code' => '123456'])
->assertStatus(422)->assertJsonValidationErrors(['code']);
});
test('register/verify отклоняет истёкший код', function () use ($startAndGetCode) {
$code = $startAndGetCode();
$this->travel(16)->minutes();
$this->postJson('/api/auth/register/verify', ['code' => $code])
->assertStatus(422)->assertJsonValidationErrors(['code']);
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
});
// ---------------------------------------------------------------------------
// Task 5: register/resend
// ---------------------------------------------------------------------------
test('register/resend в течение cooldown возвращает 429', function () use ($startAndGetCode) {
$startAndGetCode();
$this->postJson('/api/auth/register/resend')->assertStatus(429);
});
test('register/resend после cooldown шлёт новый код', function () use ($startAndGetCode) {
$startAndGetCode();
Mail::fake(); // сбрасываем счётчик отправок
$this->travel(61)->seconds();
$this->postJson('/api/auth/register/resend')->assertOk();
Mail::assertSent(RegisterEmailVerificationCode::class, 1);
});
test('register/resend без начатой регистрации возвращает 422', function () {
$this->postJson('/api/auth/register/resend')
->assertStatus(422)->assertJsonValidationErrors(['code']);
});
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('backfill: добавляет root-link для проекта-субдомена с уже-существующими линками', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
]);
$subdomainSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'krasnoyarsk.carmoney.ru',
'supplier_external_id' => 'ext-sub',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$rootSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext-root',
'current_limit' => 100,
'sync_status' => 'ok',
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $subdomainSp->id,
'platform' => 'B2',
'subject_code' => null,
]);
$exitCode = Artisan::call('supplier:backfill-root-links');
expect($exitCode)->toBe(0);
expect(
DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists()
)->toBeTrue();
});
it('backfill: idempotent — повторный прогон ничего не добавляет', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'client.carmoney.ru',
]);
$subSp = SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'client.carmoney.ru',
'supplier_external_id' => 'ext1', 'current_limit' => 100, 'sync_status' => 'ok',
]);
SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext2', 'current_limit' => 100, 'sync_status' => 'ok',
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
'platform' => 'B2', 'subject_code' => null,
]);
Artisan::call('supplier:backfill-root-links');
$afterFirst = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
Artisan::call('supplier:backfill-root-links');
$afterSecond = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($afterFirst)->toBe(2);
expect($afterSecond)->toBe(2);
});
it('backfill --dry-run: ничего не пишет в БД', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'next.vashinvestor.ru',
]);
$subSp = SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'next.vashinvestor.ru',
'supplier_external_id' => 'extn1', 'current_limit' => 100, 'sync_status' => 'ok',
]);
SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru',
'supplier_external_id' => 'extn2', 'current_limit' => 100, 'sync_status' => 'ok',
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
'platform' => 'B2', 'subject_code' => null,
]);
Artisan::call('supplier:backfill-root-links', ['--dry-run' => true]);
$count = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($count)->toBe(1);
});
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
// Гейт SaaS-admin зоны (middleware EnsureSaasAdmin). Вне local/testing зона
// закрыта (503), кроме случая включённого временного флага тест-деплоя.
it('blocks saas-admin area outside local/testing without bypass flag', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
get('/api/admin/tenants')->assertStatus(503);
});
it('allows saas-admin area when test bypass flag is enabled', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
expect(get('/api/admin/tenants')->status())->not->toBe(503);
});
@@ -7,11 +7,16 @@ use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
// DatabaseTransactions — per-test isolation.
uses(DatabaseTransactions::class);
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
@@ -148,3 +153,82 @@ it('idempotency: pre-existing supplier_project row is reused, channel not called
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
// Spec 2026-05-22: автолинковка субдомен-проекта к корневому supplier_project.
// Тесты идут по handleOnline-ветке (project_supplier_links заполняется только там).
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['key' => 'supplier_export_mode', 'value' => 'online'],
);
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
]);
$rootSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext-root-b2',
'current_limit' => 100,
'sync_status' => 'ok',
]);
// handleOnline path использует SupplierPortalClient::saveProjectMultiFlag, не channel.
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
'B1' => 700101, 'B2' => 700102, 'B3' => 700103,
]);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($ownLinks)->toBe(4);
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists();
expect($rootLink)->toBeTrue();
});
it('site root-level project: does NOT create additional links (no recursion to TLD)', function () {
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['key' => 'supplier_export_mode', 'value' => 'online'],
);
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'carmoney.ru',
]);
SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'ru',
'supplier_external_id' => 'ext-ru-b2',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
'B1' => 700201, 'B2' => 700202, 'B3' => 700203,
]);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($linksCount)->toBe(3);
});
@@ -21,6 +21,7 @@ declare(strict_types=1);
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
@@ -46,6 +47,14 @@ test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void {
// Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди,
// где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user
// SELECT по projects падает 42704 (unrecognized configuration parameter
// "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS).
expect(SyncSupplierProjectJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
@@ -213,6 +213,103 @@ it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_proje
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression: если донора удалили на портале, в нашей БД остаются supplier_projects
// с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id
// портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли
// external_id на портале (listProjects), и пересоздавать недостающих in-place
// (НЕ удаляя записи — на них могут висеть лиды/списания).
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79990001122',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [],
'delivery_days_mask' => 31,
]);
// Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров.
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79990001122',
'subject_code' => null,
'supplier_external_id' => 'DEAD'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
// Первый load = проверка существования → донор удалён (пусто).
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
// Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры.
return Http::response(['projects' => [
['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
]], 200);
},
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены.
$sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']);
});
it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void {
// Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus
// read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending"
// forever even though the stack is synced. Online must populate them too.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'uisync.example.com',
'is_active' => true,
'daily_limit_target' => 5,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
]], 200),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$project->refresh();
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
expect($project->aggregateSyncStatus())->toBe('ok');
});
// ---------------------------------------------------------------------------
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
// ---------------------------------------------------------------------------
@@ -250,3 +347,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n
// Batch: no pivot rows (nightly job fills them)
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
});
// ---------------------------------------------------------------------------
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
// ---------------------------------------------------------------------------
it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void {
// Regression: job ran on the default RLS-enforced connection. On a real queue worker
// (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC)
// the very first Project::find() dies with SQLSTATE 42704 before any supplier contact,
// so the supplier project is never created and the UI sticks on "Sync pending".
// Every sibling supplier job (SyncSupplierProjectsJob/DeleteSupplierProjectJob/…) uses
// pgsql_supplier; this one must too. On dev (postgres superuser) RLS is bypassed, so we
// assert the *connection* the queries run on rather than RLS enforcement.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'conn-test.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '8001', 'src' => 'rt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
['id' => '8002', 'src' => 'bl', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
['id' => '8003', 'src' => 'mt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
]], 200),
]);
// Listen only during the job run (factory queries above are already done).
$projectConnections = [];
DB::listen(function ($query) use (&$projectConnections): void {
// '"projects"' (quoted table) does NOT match '"supplier_projects"' or
// '"project_supplier_links"', so this captures only the projects table.
if (str_contains($query->sql, '"projects"')) {
$projectConnections[] = $query->connectionName;
}
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
expect($projectConnections)->not->toBeEmpty();
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
});
@@ -480,3 +480,57 @@ test('writes supplier_sync_log row for each successful action', function (): voi
->and($log->http_status)->toBe(200)
->and($log->error_message)->toBeNull();
});
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79993334455',
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'regions' => [],
]);
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::on('pgsql_supplier')->forceCreate([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79993334455',
'subject_code' => null,
'supplier_external_id' => 'GONE'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
return Http::response(['projects' => [
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
]], 200);
},
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
+91
View File
@@ -239,3 +239,94 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
});
describe('ProjectsView 18:00 cutoff banner', () => {
beforeEach(() => {
localStorage.clear();
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
});
});
it('shows the cutoff banner with the 18:00 deadline by default', async () => {
const wrapper = factory();
await flushPromises();
const banner = wrapper.find('[data-testid="cutoff-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('18:00');
});
it('hides the banner after the close button and remembers it in localStorage', async () => {
const wrapper = factory();
await flushPromises();
await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1');
});
it('stays hidden on next mount when previously dismissed', async () => {
localStorage.setItem('projects.cutoffBannerDismissed', '1');
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
});
});
// 2026-05-22: при total > per_page фронт должен показывать переключатель
// страниц (бэкенд уже отдаёт постранично — без UI пользователь видел только
// первые 20 проектов из 125).
describe('ProjectsView pagination', () => {
function makeCard(id: number) {
return {
id,
name: `P${id}`,
signal_type: 'site' as const,
signal_identifier: `${id}.ru`,
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok' as const,
};
}
it('renders pagination control when total > per_page', async () => {
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(true);
});
it('does NOT render pagination when total <= per_page', async () => {
const items = [makeCard(1), makeCard(2)];
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: items, meta: { total: 2, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(false);
});
it('changing page triggers refetch with new page param', async () => {
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
const mockGet = axios.get as unknown as ReturnType<typeof vi.fn>;
mockGet.mockResolvedValue({
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
mockGet.mockClear();
// Emulate user picking page 2 via VPagination's model-value.
const pagination = wrapper.findComponent({ name: 'VPagination' });
expect(pagination.exists()).toBe(true);
pagination.vm.$emit('update:modelValue', 2);
await flushPromises();
expect(mockGet).toHaveBeenCalledWith(
'/api/projects',
expect.objectContaining({ params: expect.objectContaining({ page: 2 }) }),
);
});
});
+40 -24
View File
@@ -1,13 +1,16 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import RegisterView from '../../resources/js/views/auth/RegisterView.vue';
// Smoke-тесты RegisterView. Ключевое: проверяем что 3-й «маркетинговый» click-wrap
// из v8_login.html НЕ присутствует — он противоречит ТЗ §1.5/§4.1 (расхождение #2
// handoff vs ТЗ из реестра v1.13). Только два обязательных: оферта + ПДн.
// Мокаем API-слой, чтобы тестировать переход шаг1 → шаг2 без сети.
vi.mock('../../resources/js/api/auth', () => ({
registerStart: vi.fn().mockResolvedValue({ message: 'ok', email: 'manager@yourcompany.ru' }),
registerVerify: vi.fn().mockResolvedValue({ user: { id: 1 }, requires_2fa: false }),
registerResend: vi.fn().mockResolvedValue({ message: 'ok' }),
}));
const mountRegister = async () => {
const router = createRouter({
@@ -15,6 +18,7 @@ const mountRegister = async () => {
routes: [
{ path: '/register', name: 'register', component: RegisterView },
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
{ path: '/dashboard', name: 'dashboard', component: { template: '<div>stub</div>' } },
],
});
await router.push('/register');
@@ -27,43 +31,55 @@ const mountRegister = async () => {
describe('RegisterView.vue', () => {
it('монтируется и содержит заголовок «Создать аккаунт»', async () => {
const wrapper = await mountRegister();
expect(wrapper.exists()).toBe(true);
expect(wrapper.text()).toContain('Создать аккаунт');
});
it('содержит поля email/password с правильным autocomplete', async () => {
it('шаг 1 содержит email/телефон/пароль и кнопку «Получить код»', async () => {
const wrapper = await mountRegister();
expect(wrapper.find('input[type="email"]').attributes('autocomplete')).toBe('email');
expect(wrapper.find('input[type="password"]').attributes('autocomplete')).toBe('new-password');
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="tel"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Получить код');
});
it('телефон форматируется по маске при вводе', async () => {
const wrapper = await mountRegister();
const tel = wrapper.find('input[type="tel"]');
await tel.setValue('89123456789');
expect((tel.element as HTMLInputElement).value).toBe('+7 (912) 345-67-89'); // gitleaks:allow
});
it('содержит ровно 2 click-wrap-чекбокса (оферта + ПДн), без маркетингового', async () => {
const wrapper = await mountRegister();
const checkboxes = wrapper.findAll('input[type="checkbox"]');
expect(checkboxes).toHaveLength(2);
const text = wrapper.text();
expect(text).toContain('оферту');
expect(text).toContain('политикой обработки персональных данных');
expect(text).not.toContain('информационных сообщений');
expect(text).not.toContain('маркетинг');
});
it('содержит ссылку на /login', async () => {
const wrapper = await mountRegister();
const links = wrapper.findAll('a').map((a) => a.text());
expect(links.some((t) => t.includes('Войдите'))).toBe(true);
expect(wrapper.text()).toContain('оферту');
expect(wrapper.text()).toContain('политикой обработки персональных данных');
expect(wrapper.text()).not.toContain('маркетинг');
});
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
const wrapper = await mountRegister();
const toggle = wrapper.find('[aria-label="Показать пароль"]');
expect(toggle.exists()).toBe(true);
expect(toggle.attributes('role')).toBe('button');
await toggle.trigger('click');
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
});
// keyboard activation (Enter) — toggle back
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
it('после заполнения формы и «Получить код» переходит к вводу кода', async () => {
const wrapper = await mountRegister();
await wrapper.find('input[type="email"]').setValue('manager@yourcompany.ru');
await wrapper.find('input[type="tel"]').setValue('9123456789');
await wrapper.find('input[type="password"]').setValue('fresh-pass-123');
const checkboxes = wrapper.findAll('input[type="checkbox"]');
await checkboxes[0].setValue(true);
await checkboxes[1].setValue(true);
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(wrapper.text()).toContain('Подтвердить и создать аккаунт');
expect(wrapper.text()).toContain('Отправить код повторно');
expect(wrapper.find('input[autocomplete="one-time-code"]').exists()).toBe(true);
});
});
+6 -5
View File
@@ -12,7 +12,7 @@ vi.mock('../../resources/js/api/client', () => ({
import {
login,
register,
registerStart,
me,
logout,
verifyTwoFactor,
@@ -49,12 +49,13 @@ describe('api/auth', () => {
expect(result.user.email).toBe('demo@x.ru');
});
it('register() POSTs /api/auth/register с accept-флагами', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } });
await register({ email: 'a@x.ru', password: 'pw', accept_offer: true, accept_pdn: true });
it('registerStart() POSTs /api/auth/register/start с email+phone+accept-флагами', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'ok', email: 'a@x.ru' } });
await registerStart({ email: 'a@x.ru', phone: '79991234567', password: 'pw', accept_offer: true, accept_pdn: true }); // gitleaks:allow
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register', {
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register/start', {
email: 'a@x.ru',
phone: '79991234567', // gitleaks:allow
password: 'pw',
accept_offer: true,
accept_pdn: true,
+24 -9
View File
@@ -4,7 +4,9 @@ import { createPinia, setActivePinia } from 'pinia';
// Мокаем api/auth до import'а auth-store.
vi.mock('../../resources/js/api/auth', () => ({
login: vi.fn(),
register: vi.fn(),
registerStart: vi.fn(),
registerVerify: vi.fn(),
registerResend: vi.fn(),
me: vi.fn(),
logout: vi.fn(),
verifyTwoFactor: vi.fn(),
@@ -131,8 +133,26 @@ describe('useAuthStore', () => {
expect(auth.isAuthenticated).toBe(false);
});
it('register() → success ставит user', async () => {
vi.mocked(authApi.register).mockResolvedValue({
it('registerStart() → success возвращает email без изменения user-state', async () => {
vi.mocked(authApi.registerStart).mockResolvedValue({ message: 'Код отправлен', email: 'new@example.ru' });
const auth = useAuthStore();
const result = await auth.registerStart({
email: 'new@example.ru',
phone: '79991234567', // gitleaks:allow
password: 'pass1234',
accept_offer: true,
accept_pdn: true,
});
expect(result.email).toBe('new@example.ru');
// user НЕ ставится на шаге 1 — аккаунт ещё не создан.
expect(auth.user).toBeNull();
expect(auth.isAuthenticated).toBe(false);
});
it('registerVerify() → success ставит user + isAuthenticated=true', async () => {
vi.mocked(authApi.registerVerify).mockResolvedValue({
user: {
id: 2,
email: 'new@example.ru',
@@ -146,12 +166,7 @@ describe('useAuthStore', () => {
});
const auth = useAuthStore();
await auth.register({
email: 'new@example.ru',
password: 'pass1234',
accept_offer: true,
accept_pdn: true,
});
await auth.registerVerify('123456');
expect(auth.user?.email).toBe('new@example.ru');
expect(auth.isAuthenticated).toBe(true);
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { phoneDigits, formatPhone, isValidPhone } from '../../resources/js/utils/phone';
// Phone numbers below are test fixtures, not real PII. gitleaks:allow
describe('phone utils', () => {
it('phoneDigits нормализует к 7XXXXXXXXXX', () => {
expect(phoneDigits('+7 (912) 345-67-89')).toBe('79123456789'); // gitleaks:allow
expect(phoneDigits('8 912 345 67 89')).toBe('79123456789'); // gitleaks:allow
expect(phoneDigits('9123456789')).toBe('79123456789'); // gitleaks:allow
expect(phoneDigits('')).toBe('');
});
it('formatPhone строит маску прогрессивно', () => {
expect(formatPhone('')).toBe('');
expect(formatPhone('7912')).toBe('+7 (912');
expect(formatPhone('79123456789')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
// лишние цифры обрезаются до 11
expect(formatPhone('791234567890000')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
});
it('isValidPhone true только для полного 7+10', () => {
expect(isValidPhone('+7 (912) 345-67-89')).toBe(true); // gitleaks:allow
expect(isValidPhone('7912345')).toBe(false);
expect(isValidPhone('')).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use App\Services\PhoneNormalizer;
test('нормализует разные форматы RU-номера в 7XXXXXXXXXX', function (string $input, ?string $expected) {
expect(PhoneNormalizer::normalize($input))->toBe($expected);
})->with([
'маска +7' => ['+7 (912) 345-67-89', '79123456789'],
'через 8' => ['8 (912) 345-67-89', '79123456789'],
'голые 7+10' => ['79123456789', '79123456789'],
'голые 10' => ['9123456789', '79123456789'],
'с мусором' => ['тел: +7-912-345-67-89 ', '79123456789'],
'слишком коротко' => ['12345', null],
'слишком длинно' => ['791234567890123', null],
'пусто' => ['', null],
]);
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Support\SupplierIdentifier;
use Tests\TestCase;
uses(TestCase::class);
it('extracts root domain from subdomain', function (): void {
expect(SupplierIdentifier::extractRootDomain('krasnoyarsk.carmoney.ru'))->toBe('carmoney.ru');
expect(SupplierIdentifier::extractRootDomain('client.carmoney.ru'))->toBe('carmoney.ru');
expect(SupplierIdentifier::extractRootDomain('next.vashinvestor.ru'))->toBe('vashinvestor.ru');
expect(SupplierIdentifier::extractRootDomain('cabinet.caranga.ru'))->toBe('caranga.ru');
});
it('returns null for already-root domain', function (): void {
expect(SupplierIdentifier::extractRootDomain('carmoney.ru'))->toBeNull();
expect(SupplierIdentifier::extractRootDomain('заложитьптс.рф'))->toBeNull();
});
it('returns null for non-domain identifiers', function (): void {
expect(SupplierIdentifier::extractRootDomain('7800XXXXXXX'))->toBeNull();
expect(SupplierIdentifier::extractRootDomain(''))->toBeNull();
expect(SupplierIdentifier::extractRootDomain(' '))->toBeNull();
expect(SupplierIdentifier::extractRootDomain('TINKOFF'))->toBeNull();
});
+29
View File
@@ -1,6 +1,17 @@
# Глоссарий проекта Лидерра
# Формат: одно слово на строке. Кириллица в нижнем регистре.
# Test-deploy Yandex Cloud (2026-05-21)
hba
htpasswd
lsb
nslookup
scp
хостить
tos
прода
ребута
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
@@ -1588,3 +1599,21 @@ lemed
батч
ретраит
шеринге
# Supplier dead-donor fix + баннер 18:00 (2026-05-21)
дрейфнувшей
дропа
коммитах
доустановлены
дочерпывание
creds
незавершёнку
субдомен
субдомены
субдомена
брейнсторминг
брейнсторминга
гэп
артизан
Артизан
Sps
+130
View File
@@ -0,0 +1,130 @@
# Лидерра — тест-сервер (Yandex Cloud) — runbook
**Создан:** 2026-05-21. Тестовое окружение для ручной проверки (заказчик + Claude). Не продакшен.
Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
План: `docs/superpowers/plans/2026-05-21-test-deploy-yandex-cloud.md`.
## Доступ
- **URL (HTTP, временно):** `http://111.88.246.137` — статический IP YC.
- **HTTPS / домен:** добавляется после покупки домена (см. «Включить HTTPS»).
- **Дверь сайта (HTTP Basic Auth):** логин `liderra` — пароль в `/home/ubuntu/liderra-secrets.txt` на сервере (ключ `basic_auth`).
- **Демо-вход в портал:** `admin@demo.local` / `password` (tenant `demo`, 3 проекта, демо-сделки).
- **SSH:** `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137` (ключ на dev-машине; пароль входа отключён).
- **YC:** облако `cloud-sasha261185`, каталог `default`, VM `liderra-test` (ru-central1-a, 2vCPU/2GB/20%), SG `liderra-test-sg` (22/80/443).
## Состав
- Ubuntu 24.04: nginx (Basic Auth, webhook `/api/webhook/*` без auth) → PHP-FPM 8.3 → Laravel.
- PostgreSQL 16 (БД `liderra`), Redis (sessions+cache+queue, predis).
- Код в `/var/www/liderra/app`; фронтенд `public/build` (собирается на dev, заливается scp).
- Службы: `liderra-queue.service` (queue worker, systemd, enabled) + cron `/etc/cron.d/liderra-scheduler` (schedule:run). Все автозапускаются после ребута.
## Важные отклонения от прод-дизайна (на решение позже)
- **DB-роль приложения = `crm_app_user` (RLS включена)** — изоляция бизнес-данных между клиентами
**работает** (deals/projects/billing/… строгие политики). Чтобы вход работал под строгой ролью,
RLS-политики на таблицах `users` + `auth_log` сделаны «дружелюбными ко входу»: пропускают запрос,
когда tenant-контекст ещё не установлен (auth/login), и фильтруют по тенанту после. Это server-only
правка политик (не в schema.sql); для прода — кандидат в нормативную схему.
- **Админка SaaS `/admin/*` под `crm_app_user` НЕ работает** (нет доступа к saas-таблицам — REVOKE).
Для теста «от лица клиентов» не нужна. Понадобится — переключать admin-запросы на `crm_admin_user`
(connection-switch в middleware `EnsureSaasAdmin`) — отдельная доработка.
- **`SAAS_ADMIN_TEST_BYPASS=true`** — временный флаг (для будущей админки). Убрать после Yandex SSO (Б-1).
- **Почта** = `log` (письма в файл). **APP_DEBUG=false**, **APP_ENV=production**.
- Установлены dev-зависимости (faker нужен для сидов).
## Тестовые клиенты
| Логин | Пароль | Компания |
|---|---|---|
| `admin@demo.local` | `password` | Demo (3 проекта + демо-сделки) |
| `client1@liderra.test` | `password` | Компания 1 (2 проекта) |
| `client2@liderra.test` | `password` | Компания 2 (2 проекта) |
| `client3@liderra.test` | `password` | Компания 3 (2 проекта) |
| `client4@liderra.test` | `password` | Компания 4 (2 проекта) |
Изоляция проверена вживую: каждый видит только свои проекты (HTTP-логин + `/api/projects`).
## Каналы миграции с поставщиком (настроены 2026-05-21)
Все 3 канала с `crm.bp-gr.ru` подняты и проверены вживую на тест-сервере.
### Предпосылки (доустановлены сверх базового деплоя — в исходном runbook их не было)
- **Node.js 20** (NodeSource) + **Playwright** (`app/playwright/node_modules`, `npm install`) + **Chromium**
в `/var/www/.cache/ms-playwright/` (HOME у `www-data` = `/var/www`; ставить через
`sudo HOME=/var/www .../playwright install chromium` затем `chown -R www-data:www-data /var/www/.cache`,
иначе artisan от www-data не находит браузер). Без них логин к поставщику (Yii2-форма, JS) не работает
→ CSV-сверка и экспорт мертвы (`PlaywrightBridge exit code 127: node: not found`).
- `PlaywrightBridge::TIMEOUT_SECONDS` поднят **75 → 180** (`app/app/Services/Supplier/PlaywrightBridge.php`):
на 2 ГБ VM холодный старт Chromium ~65 c, в 75 не укладывался. Бэкап `*.bak.20260521`.
- `.env`: `SUPPLIER_LOGIN` / `SUPPLIER_PASSWORD` (те же, что на dev). Бэкап `.env.bak.20260521-*`.
- `system_settings.supplier_webhook_secret` — 48-hex (DemoSeeder ставит короткий → guard `<32` → webhook молча 404).
Копия в `/home/ubuntu/liderra-secrets.txt`.
- `system_settings.supplier_ip_allowlist` = `["0.0.0.0/0"]` — на `APP_ENV=production` пустой массив fail-closed (404 всем).
**TODO: сузить** до IP поставщика (в логе видели `92.53.65.242`).
### Канал 1 — приём webhook'а (вход, основной)
- POST `http://111.88.246.137/api/webhook/supplier/<secret>` (nginx `^~ /api/webhook/` без Basic Auth).
- Проверено: правильный secret → 202, дубль `vid` → 200 `already_processed`, битый secret → 404.
### Канал 2 — CSV-дочерпывание (вход, резерв)
- `CsvReconcileJob`, scheduler каждые 30 мин (cron `schedule:run` ежеминутно). Прогон вживую: 185 строк, status `ok`, drift 0.
- Ручной запуск: `sudo -u www-data php artisan tinker --execute='App\Jobs\Supplier\CsvReconcileJob::dispatchSync()'`.
### Канал 3 — экспорт проектов (выход)
- `SupplierProjectChannel::createProject` / `SupplierPortalClient::deleteProject`. Проверено: create+delete
тестового проекта (`external_id=12764235`), сверка `listProjects` — следов у поставщика нет.
### Supplier-портал
- `crm.bp-gr.ru → /admin/user/api`: «Апи ссылка» = `http://111.88.246.137/api/webhook/supplier/<secret>`,
«Апи протокол» = HTTP, «Апи статус» = Активный. Поставщик HTTP-URL принимает.
- ⚠️ Поле URL **одно** → после переключения на тест-сервер dev-машина живых лидов **не получает**.
- Сессия логина: Redis DB 1, ключ `liderra-database-liderra-cache-supplier:session` (TTL 6h, refresh-крон/`supplier:session:refresh`).
### Сделать позже
- Привязать `client1..4` к реальным каналам поставщика через pivot `project_supplier_links` (иначе лиды = ghost без сделок).
- HTTPS после покупки домена → URL у поставщика на https.
- Сузить `supplier_ip_allowlist`.
## Обновить версию
На dev-машине:
```powershell
npm --prefix app run build
git -C <repo> archive --format=tar HEAD app db -o $env:TEMP\liderra.tar
scp -i ~/.ssh/liderra_deploy $env:TEMP\liderra.tar ubuntu@111.88.246.137:/tmp/
scp -i ~/.ssh/liderra_deploy -r app\public\build ubuntu@111.88.246.137:/tmp/build
```
На сервере:
```bash
tar -xf /tmp/liderra.tar -C /var/www/liderra
rm -rf /var/www/liderra/app/public/build && cp -r /tmp/build /var/www/liderra/app/public/build
bash /var/www/liderra/redeploy.sh
```
## Включить HTTPS (после покупки домена)
1. DNS: A-запись `test.<домен>` (и/или `demo.<домен>` для subdomain-tenant) → `111.88.246.137`.
2. На сервере: в `/etc/nginx/sites-available/liderra` заменить `server_name _;` на домен, `nginx -t && systemctl reload nginx`.
3. `sudo certbot --nginx -d test.<домен> --non-interactive --agree-tos -m <email> --redirect`.
4. В `.env` обновить `APP_URL=https://test.<домен>`, затем `php artisan optimize`.
## Остановить / удалить (прекратить оплату)
- Остановить VM: `yc compute instance stop liderra-test` (диск/IP сохраняются, мелкая плата).
- Удалить совсем: `yc compute instance delete liderra-test` + `yc vpc address delete <id>`.
## После теста — обязательно
- **Отозвать OAuth-токен Yandex Cloud** (Яндекс ID → Безопасность → сторонние приложения).
- При переходе к прод-конфигу: убрать `SAAS_ADMIN_TEST_BYPASS`, вернуть `crm_app_user` (после auth-rework).
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-21T01:53:48.034Z
Last updated: 2026-05-22T09:23:38.723Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,14 +8,14 @@ Last updated: 2026-05-21T01:53:48.034Z
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 16 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) |
| C5 Observer-coverage | | 189 episode(s) this month · Stop-hook + post-commit OK |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 16 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Observer evidence: 189 episodes this month, 0 observer_error markers, 220 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 5
- Last /brain-retro: 2 day(s) ago
- Last /brain-retro: 3 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Алерт-индикаторы
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,631 @@
# Тестовый деплой портала Лидерра в Yandex Cloud — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task (inline — план содержит интерактивные шаги заказчика: создание VM, DNS, deploy-key). Steps use checkbox (`- [ ]`) syntax.
**Goal:** Поднять рабочую копию портала в интернете на одной Linux-VM в Yandex Cloud по адресу `https://<поддомен>` с HTTPS, доступом только для заказчика+Claude, для ручного теста.
**Architecture:** Одна Ubuntu 24.04 VM: nginx (HTTPS + Basic Auth) → PHP-FPM 8.3 → портал (Laravel 13 + собранный Vue) → PostgreSQL 16 + Redis 7 на той же машине; queue worker + scheduler как systemd-службы. Фронтенд собирается на dev-машине и заливается. Настоящие роли БД (RLS включён). Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
**Tech Stack:** Yandex Cloud Compute, Ubuntu 24.04 LTS, nginx, PHP 8.3-FPM, PostgreSQL 16, Redis 7, Certbot/Let's Encrypt, systemd, OpenSSH.
**Условные обозначения:** 🧑 = шаг заказчика (веб-интерфейс/решение), 🤖 = шаг Claude (Bash/SSH). Плейсхолдеры: `<SERVER_IP>`, `<DOMAIN>` (например `test.example.ru`), `<BASIC_USER>`/`<BASIC_PASS>` (дверь сайта) — заполняются по ходу.
---
## Фаза 0 — Подготовка на dev-машине (🤖, до создания сервера)
### Task 0.1: Проверить SSH-клиент и сгенерировать ключ деплоя
**Files:** `~/.ssh/liderra_deploy`, `~/.ssh/liderra_deploy.pub` (на dev-машине)
- [ ] **Step 1: Проверить наличие OpenSSH**
Run: `ssh -V; ssh-keygen --help 2>&1 | Select-Object -First 1`
Expected: версия OpenSSH (например `OpenSSH_for_Windows_9.x`). Если нет — поставить «OpenSSH Client» через Settings → Optional Features.
- [ ] **Step 2: Сгенерировать ключ-пару (без пароля, ed25519)**
Run (PowerShell):
```powershell
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\liderra_deploy" -C "liderra-test-deploy" -N '""'
```
Expected: созданы `liderra_deploy` (приватный) и `liderra_deploy.pub` (публичный).
- [ ] **Step 3: Показать публичный ключ заказчику**
Run: `Get-Content "$env:USERPROFILE\.ssh\liderra_deploy.pub"`
Expected: строка `ssh-ed25519 AAAA... liderra-test-deploy`. Отдать заказчику для вставки при создании VM (Task 1.2).
### Task 0.2: Код-правка — временный флаг доступа к админке (TDD)
**Files:**
- Modify: `app/config/app.php` (добавить ключ `saas_admin_test_bypass`)
- Modify: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Test: `app/tests/Feature/Middleware/EnsureSaasAdminTest.php` (создать или дополнить)
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Feature/Middleware/EnsureSaasAdminTest.php`:
```php
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
it('blocks admin area in production by default', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
// любой admin-маршрут под EnsureSaasAdmin; подставить реальный из routes
$response = get('/api/admin/tenants');
expect($response->status())->toBe(503);
});
it('allows admin area in production when test bypass flag is on', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
$response = get('/api/admin/tenants');
expect($response->status())->not->toBe(503);
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: второй тест FAIL (сейчас middleware всегда 503 вне local/testing).
- [ ] **Step 3: Добавить ключ конфига**
В `app/config/app.php` добавить (рядом с другими ключами):
```php
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
```
- [ ] **Step 4: Поправить middleware**
В `app/app/Http/Middleware/EnsureSaasAdmin.php` заменить тело `handle`:
```php
public function handle(Request $request, Closure $next): Response
{
if (app()->environment('local', 'testing')) {
return $next($request);
}
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
```
- [ ] **Step 5: Запустить тест — зелёный**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: оба PASS.
- [ ] **Step 6: Линт + commit**
Run: `cd app; composer pint; composer stan`
Expected: 0 ошибок.
```bash
git add app/config/app.php app/app/Http/Middleware/EnsureSaasAdmin.php app/tests/Feature/Middleware/EnsureSaasAdminTest.php
git commit -m "feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)"
```
> NB: маршрут `/api/admin/tenants` в тесте — подставить реальный admin-маршрут из `app/routes/`. Уточнить на Step 1 (grep по `EnsureSaasAdmin`).
### Task 0.3: Собрать фронтенд для прода
- [ ] **Step 1: Прод-сборка**
Run: `npm --prefix app run build`
Expected: создан `app/public/build/` с манифестом и ассетами, ошибок нет.
- [ ] **Step 2: Зафиксировать факт сборки**
Сборка не коммитится (build в .gitignore) — будет залита на сервер в Task 3.3 через scp. Проверить: `Test-Path app/public/build/manifest.json` → True.
---
## Фаза 1 — Создание сервера (🧑 заказчик в консоли YC, по инструкции Claude)
### Task 1.1: Зарезервировать статический публичный IP
- [ ] **Step 1:** YC Console → Virtual Private Cloud → IP-адреса → «Зарезервировать адрес» → зона `ru-central1-a`.
- [ ] **Step 2:** Записать выданный IP → это `<SERVER_IP>` (нужен для DNS; статический, чтобы адрес не менялся при перезагрузке).
### Task 1.2: Создать виртуальную машину
- [ ] **Step 1:** Compute Cloud → «Создать ВМ».
- [ ] **Step 2:** Параметры:
- Имя: `liderra-test`; зона `ru-central1-a`.
- Образ: **Ubuntu 24.04 LTS**.
- vCPU 2, RAM 2 ГБ, **гарантированная доля vCPU 20%** (дёшево; сборки идут на dev-машине).
- Диск: SSD 20 ГБ.
- Публичный адрес: выбрать **зарезервированный** из Task 1.1.
- Доступ: логин `deploy`; SSH-ключ — вставить публичный ключ из Task 0.1 Step 3.
- [ ] **Step 3:** Создать. Дождаться статуса RUNNING.
### Task 1.3: Открыть порты (группа безопасности)
- [ ] **Step 1:** VPC → Группы безопасности → группа сети ВМ → правила входящего трафика.
- [ ] **Step 2:** Разрешить TCP **22, 80, 443** (источник `0.0.0.0/0`; 22 можно сузить до IP заказчика/dev — но для простоты теста оставить открытым).
- [ ] **Step 3:** Сообщить Claude `<SERVER_IP>` → переходим к Фазе 2.
---
## Фаза 2 — Базовая настройка сервера (🤖 по SSH)
### Task 2.1: Первое подключение
- [ ] **Step 1: Подключиться**
Run: `ssh -i "$env:USERPROFILE\.ssh\liderra_deploy" -o StrictHostKeyChecking=accept-new deploy@<SERVER_IP> "echo OK; lsb_release -d"`
Expected: `OK` + `Ubuntu 24.04`.
- [ ] **Step 2: Обновить пакеты**
Run: `ssh ... deploy@<SERVER_IP> "sudo apt-get update && sudo apt-get -y upgrade"`
Expected: завершается без ошибок.
### Task 2.2: Установить стек
- [ ] **Step 1: Установить пакеты**
Run одной командой по SSH:
```bash
sudo apt-get install -y nginx \
php8.3-fpm php8.3-cli php8.3-pgsql php8.3-redis php8.3-mbstring \
php8.3-xml php8.3-curl php8.3-bcmath php8.3-zip php8.3-gd php8.3-intl \
postgresql postgresql-contrib redis-server git unzip certbot python3-certbot-nginx \
apache2-utils
```
Expected: установлено без ошибок (`apache2-utils` даёт `htpasswd`).
- [ ] **Step 2: Установить Composer**
```bash
php -r "copy('https://getcomposer.org/installer','/tmp/ci.php');" \
&& sudo php /tmp/ci.php --install-dir=/usr/local/bin --filename=composer
```
Run: `ssh ... "composer --version; php -v | head -1"`
Expected: Composer 2.x; PHP 8.3.
- [ ] **Step 3: Проверить службы**
Run: `ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server"`
Expected: `active` × 4.
---
## Фаза 3 — База, код, конфиг (🤖 по SSH)
> **Порядок исполнения внутри фазы:** 3.2 (код на сервере — db/-скрипты приезжают с репо) → 3.1 (БД и роли) → 3.3 (фронтенд) → 3.4 (.env) → 3.5 (схема через migrate + grants + seed). Здесь нумерация по смыслу, но db-скрипты есть только после clone.
>
> **DB-роли (из `db/00_create_roles.sql` v1.1 + `app/config/database.php`):** пароли передаются psql через `-v` (НЕ `ALTER ROLE`). Схема грузится миграцией `load_initial_schema` (она делает `DB::unprepared(schema.sql)`) под ролью `crm_migrator` (BYPASSRLS+CREATEDB). Гранты — `db/02_grants.sql`. Рантайм — `crm_app_user` (RLS). Supplier-джобы — `crm_supplier_worker` (BYPASSRLS) через connection `pgsql_supplier`. Connection `pgsql_migrator` в конфиге НЕТ → для миграций временно подменяем `DB_USERNAME` на `crm_migrator` (default-connection `pgsql`), потом возвращаем на `crm_app_user`.
### Task 3.1: Создать БД и роли
**Files (на сервере):** `db/00_create_roles.sql` (после clone в 3.2).
- [ ] **Step 1: Сгенерировать пароли ролей (на dev или сервере)**
Run: `ssh ... "for r in app admin migrator audit supplier; do echo \$r=\$(openssl rand -hex 16); done"`
Expected: 5 строк вида `app=...`. Сохранить как `<APP_DB_PASS>` / `<ADMIN_DB_PASS>` / `<MIGRATOR_DB_PASS>` / `<AUDIT_DB_PASS>` / `<WORKER_DB_PASS>` (в безопасное место, не в git).
- [ ] **Step 2: Создать БД**
```bash
ssh ... "sudo -u postgres createdb liderra"
```
Expected: без ошибок.
- [ ] **Step 3: Создать роли с паролями (через -v)**
```bash
ssh ... "sudo -u postgres psql -d liderra \
-v crm_app_password='<APP_DB_PASS>' \
-v crm_admin_password='<ADMIN_DB_PASS>' \
-v crm_migrator_password='<MIGRATOR_DB_PASS>' \
-v crm_audit_writer_password='<AUDIT_DB_PASS>' \
-v crm_supplier_worker_password='<WORKER_DB_PASS>' \
-f /var/www/liderra/db/00_create_roles.sql"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\du' | grep -E 'crm_(app|migrator|supplier)'"`
Expected: 5 ролей созданы (`crm_app_user`, `crm_admin_user`, `crm_migrator`, `crm_audit_writer`, `crm_supplier_worker`).
- [ ] **Step 4: Разрешить TCP-вход ролям (pg_hba)**
> Роли ходят через 127.0.0.1 (scram). Убедиться, что `pg_hba.conf` имеет строку `host all all 127.0.0.1/32 scram-sha-256` (на Ubuntu по умолчанию есть). Если нет — добавить и `sudo systemctl reload postgresql`.
Run: `ssh ... "sudo grep -E '127.0.0.1/32' /etc/postgresql/16/main/pg_hba.conf"`
Expected: строка с `scram-sha-256` (или `md5`).
### Task 3.2: Выложить код (deploy-key + clone)
- [ ] **Step 1: Сгенерировать deploy-key на сервере**
```bash
ssh ... "ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N '' -C 'liderra-server'; cat ~/.ssh/github_deploy.pub"
```
Expected: публичный ключ сервера.
- [ ] **Step 2 (🧑): Добавить ключ в GitHub**
Заказчик: GitHub → репо `CoralMinister/lidpotok` → Settings → Deploy keys → Add → вставить ключ (read-only, без write).
- [ ] **Step 3: Настроить SSH для GitHub + clone**
```bash
ssh ... 'cat >> ~/.ssh/config <<EOF
Host github.com
IdentityFile ~/.ssh/github_deploy
StrictHostKeyChecking accept-new
EOF
sudo mkdir -p /var/www && sudo chown deploy:deploy /var/www
git clone git@github.com:CoralMinister/lidpotok.git /var/www/liderra
cd /var/www/liderra && git checkout main && git log -1 --oneline'
```
Expected: репозиторий склонирован, HEAD на нужном коммите (с флагом из Task 0.2 — убедиться, что коммит влит в `main`; иначе `git checkout <ветка>`).
- [ ] **Step 4: composer install**
```bash
ssh ... "cd /var/www/liderra/app && composer install --no-dev --optimize-autoloader --no-interaction"
```
Expected: зависимости установлены, 0 ошибок.
### Task 3.3: Залить собранный фронтенд
- [ ] **Step 1: Скопировать build на сервер**
Run (с dev-машины):
```powershell
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" -r app/public/build deploy@<SERVER_IP>:/var/www/liderra/app/public/
```
Expected: `manifest.json` + ассеты на сервере.
### Task 3.4: Production .env
- [ ] **Step 1: Создать .env на сервере**
```bash
ssh ... 'cat > /var/www/liderra/app/.env <<EOF
APP_NAME=Liderra
APP_ENV=production
APP_DEBUG=false
APP_URL=https://<DOMAIN>
APP_LOCALE=ru
APP_FALLBACK_LOCALE=ru
APP_TIMEZONE=Europe/Moscow
LOG_CHANNEL=stack
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=liderra
DB_USERNAME=crm_app_user
DB_PASSWORD=<APP_DB_PASS>
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=<WORKER_DB_PASS>
SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@<DOMAIN>"
MAIL_FROM_NAME=Liderra
SAAS_ADMIN_TEST_BYPASS=true
AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets
EOF'
```
- [ ] **Step 2: APP_KEY**
```bash
ssh ... "cd /var/www/liderra/app && php artisan key:generate --force && php artisan about | head -20"
```
Expected: ключ сгенерирован; `Environment: production`, `Debug Mode: OFF`.
### Task 3.5: Схема (migrate), гранты, демо-данные, кэши
> Схему и сиды грузим под BYPASSRLS-ролью `crm_migrator`, потом возвращаем рантайм на `crm_app_user`. Подмена — временно правим `DB_USERNAME`/`DB_PASSWORD` в `.env` (это значения для default-connection `pgsql`, через которую идёт migrate/seed).
- [ ] **Step 1: Временно переключить .env на crm_migrator**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_migrator/; s/^DB_PASSWORD=.*/DB_PASSWORD=<MIGRATOR_DB_PASS>/' .env && \
grep -E '^DB_(USERNAME|PASSWORD)=' .env"
```
Expected: `DB_USERNAME=crm_migrator`.
- [ ] **Step 2: Накатить схему (миграция load_initial_schema грузит schema.sql)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan migrate --force"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\dt' | tail -3"`
Expected: миграция `load_initial_schema` отработала; десятки таблиц (схема v8.27).
- [ ] **Step 3: Создать партиции (как на dev — ручной cron вместо pg_partman)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan partitions:create-months"
```
Expected: партиции созданы (команда из ЭТАЛОН/project_phase1_strategy; если имя иное — `php artisan list | grep partition`).
- [ ] **Step 4: Применить гранты**
```bash
ssh ... "sudo -u postgres psql -d liderra -f /var/www/liderra/db/02_grants.sql"
```
Expected: гранты применены без ошибок (запуск под postgres-суперюзером — владелец/superuser, см. 00_create_roles doc вариант с crm_admin_user тоже подходит).
- [ ] **Step 5: Демо-данные (под crm_migrator, BYPASSRLS — cross-tenant сид проходит)**
```bash
# залить нужные демо-скрипты на сервер
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" app/storage/_demo_5users.php app/storage/_demo_split_tenants.php deploy@<SERVER_IP>:/var/www/liderra/app/storage/
ssh ... "cd /var/www/liderra/app && php artisan db:seed --force && php artisan tinker storage/_demo_5users.php && php artisan tinker storage/_demo_split_tenants.php"
```
Expected: 5 компаний + учётки `admin@demo.local` / `manager1..4@demo.local` (пароль `password`).
> NB: точный набор демо-скриптов сверить с ЭТАЛОН §4 (там же команда восстановления). Залить только нужные `_demo_*.php`.
- [ ] **Step 6: Вернуть рантайм-роль crm_app_user**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_app_user/; s/^DB_PASSWORD=.*/DB_PASSWORD=<APP_DB_PASS>/' .env && \
grep -E '^DB_USERNAME=' .env"
```
Expected: `DB_USERNAME=crm_app_user` (RLS будет enforce'иться в рантайме).
- [ ] **Step 7: Права и кэши**
```bash
ssh ... 'cd /var/www/liderra/app \
&& sudo chown -R deploy:www-data storage bootstrap/cache \
&& sudo chmod -R 775 storage bootstrap/cache \
&& php artisan config:cache && php artisan route:cache && php artisan view:cache'
```
Expected: кэши собраны, прав хватает.
---
## Фаза 4 — Веб, HTTPS, дверь (🤖 + 🧑 DNS)
### Task 4.1: DNS A-запись (🧑)
- [ ] **Step 1:** В панели домена создать запись `A` для `<DOMAIN>``<SERVER_IP>`.
- [ ] **Step 2 (🤖): Проверить распространение**
Run: `ssh ... "getent hosts <DOMAIN> || nslookup <DOMAIN>"`
Expected: резолвится в `<SERVER_IP>` (может занять до 30–60 мин).
### Task 4.2: nginx vhost (HTTP)
- [ ] **Step 1: Конфиг сайта**
```bash
ssh ... 'sudo tee /etc/nginx/sites-available/liderra <<EOF
server {
listen 80;
server_name <DOMAIN>;
root /var/www/liderra/app/public;
index index.php;
# дверь на весь сайт (Basic Auth), кроме webhook поставщика
location / {
auth_basic "Liderra test";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ^~ /api/webhook/ {
auth_basic off;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/liderra /etc/nginx/sites-enabled/liderra
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx'
```
Expected: `nginx -t` syntax ok; reload без ошибок.
> NB: точный префикс webhook (`/api/webhook/`) сверить с `app/routes/api.php` (grep `webhook`). Если иной — поправить `location ^~`.
- [ ] **Step 2: Создать пароль двери**
```bash
ssh ... "sudo htpasswd -bc /etc/nginx/.htpasswd <BASIC_USER> <BASIC_PASS>"
```
Expected: `.htpasswd` создан.
- [ ] **Step 3: Проверка по HTTP**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' -u <BASIC_USER>:<BASIC_PASS> http://<DOMAIN>/"`
Expected: `200` (или `302` на /login). Без креда → `401`.
### Task 4.3: HTTPS (Let's Encrypt)
- [ ] **Step 1: Выпустить сертификат**
```bash
ssh ... "sudo certbot --nginx -d <DOMAIN> --non-interactive --agree-tos -m <EMAIL> --redirect"
```
Expected: сертификат выпущен, nginx переписан на 443 + редирект с 80.
- [ ] **Step 2: Проверить HTTPS + авто-продление**
Run: `ssh ... "curl -sI -u <BASIC_USER>:<BASIC_PASS> https://<DOMAIN>/ | head -1; sudo certbot renew --dry-run 2>&1 | tail -1"`
Expected: `HTTP/2 200|302`; dry-run `Congratulations` / success.
---
## Фаза 5 — Фоновые службы (🤖)
### Task 5.1: queue worker как systemd-служба
- [ ] **Step 1: Юнит**
```bash
ssh ... 'sudo tee /etc/systemd/system/liderra-queue.service <<EOF
[Unit]
Description=Liderra queue worker
After=redis-server.service postgresql.service
[Service]
User=deploy
Restart=always
WorkingDirectory=/var/www/liderra/app
ExecStart=/usr/bin/php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now liderra-queue'
```
Run: `ssh ... "systemctl is-active liderra-queue"`
Expected: `active`.
### Task 5.2: scheduler (cron)
- [ ] **Step 1: Cron-запись**
```bash
ssh ... '( crontab -l 2>/dev/null; echo "* * * * * cd /var/www/liderra/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1" ) | crontab -'
```
Run: `ssh ... "crontab -l | grep schedule:run"`
Expected: строка присутствует.
---
## Фаза 6 — Приёмка и сопровождение (🤖)
### Task 6.1: Проверка критериев готовности (DoD)
- [ ] **Step 1: HTTPS + замочек**
Открыть `https://<DOMAIN>` в браузере (с логином двери) → валидный сертификат, портал грузится.
- [ ] **Step 2: Дверь работает**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' https://<DOMAIN>/"``401` (без креда).
- [ ] **Step 3: Вход + данные**
В браузере: `admin@demo.local` / `password` → видно 4 демо-проекта.
- [ ] **Step 4: Изоляция компаний (RLS)**
Войти `manager1@demo.local` / `password` → видна только своя компания (чужих проектов нет). Если падает SQL — зафиксировать, чинить (риск из спеки §5.4).
- [ ] **Step 5: Админка**
Открыть `/admin/...` под админом → не 503 (флаг bypass работает).
- [ ] **Step 6: Службы переживают перезагрузку**
```bash
ssh ... "sudo reboot" # подождать ~40с
ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server liderra-queue"
```
Expected: все `active`; сайт снова открывается.
### Task 6.2: Скрипт обновления + инструкция
**Files:** `/var/www/liderra/deploy.sh` (на сервере), `docs/deploy/test-server-runbook.md` (в репо)
- [ ] **Step 1: deploy.sh**
```bash
ssh ... 'cat > /var/www/liderra/deploy.sh <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/liderra
git pull
cd app
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo systemctl restart php8.3-fpm liderra-queue
echo "Deployed: \$(git -C /var/www/liderra log -1 --oneline)"
EOF
chmod +x /var/www/liderra/deploy.sh'
```
> Фронтенд при обновлении: пересобрать на dev (`npm --prefix app run build`) и `scp` build на сервер ПЕРЕД запуском deploy.sh.
- [ ] **Step 2: Runbook**
Создать `docs/deploy/test-server-runbook.md`: адрес, доступы (где лежат пароли), команда обновления, как остановить/удалить VM (прекратить оплату), напоминание убрать `SAAS_ADMIN_TEST_BYPASS` при переходе к настоящему SSO.
- [ ] **Step 3: Commit runbook**
```bash
git add docs/deploy/test-server-runbook.md
git commit -m "docs(deploy): test-server runbook"
```
---
## Открытые вопросы (заполнить при исполнении)
- `<DOMAIN>` и панель управления доменом — от заказчика.
- Точный admin-маршрут для теста (Task 0.2) и префикс webhook (Task 4.2) — grep по коду.
- Точные seed-шаги демо-учёток (Task 3.5) — по ЭТАЛОН §4.
- Пароли БД-ролей (`<APP_DB_PASS>`, `<ADMIN_DB_PASS>`, `<MIGRATOR_DB_PASS>`, `<AUDIT_DB_PASS>`, `<WORKER_DB_PASS>`) + дверь сайта (`<BASIC_PASS>`) — сгенерировать (Task 3.1 Step 1), сохранить в безопасном месте (не в git; занести в runbook-ссылку на хранилище).
- `pg_hba.conf` путь зависит от версии PG (`/etc/postgresql/16/main/`) — сверить на сервере.
@@ -0,0 +1,776 @@
# Автолинковка проекта-субдомена к корневому домену — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Подписчик субдомена (`krasnoyarsk.carmoney.ru`) автоматически получает также лиды с корневого домена (`carmoney.ru`).
**Architecture:** Утилита `SupplierIdentifier::extractRootDomain` извлекает root по последним 2 сегментам. Два sync-джоба (`SyncSupplierProjectJob` одиночный + `SyncSupplierProjectsJob` массовый) после своих основных линков добавляют ещё один блок — `insertOrIgnore` линков к `supplier_projects.unique_key = root`. Артизан-команда `supplier:backfill-root-links` делает то же для уже существующих проектов однократно (идемпотентно).
**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16. БД-соединение для записи в `project_supplier_links``pgsql_supplier` (BYPASSRLS).
**Spec:** [docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md](../specs/2026-05-22-root-domain-auto-link-design.md)
---
## File Structure
**Создаются:**
- `app/app/Support/SupplierIdentifier.php` — утилита с методом `extractRootDomain`
- `app/tests/Unit/Support/SupplierIdentifierTest.php` — unit-тесты утилиты
- `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php` — артизан-команда
- `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php` — тесты команды
**Модифицируются:**
- `app/app/Jobs/SyncSupplierProjectJob.php` — добавляется блок auto-root-link после строки 318
- `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` — добавляется блок auto-root-link после строки 427
- `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` — +2 теста на root-link
- `app/bootstrap/app.php` или `app/routes/console.php` — регистрация артизан-команды (если не auto-discovered)
---
### Task 1: Утилита `SupplierIdentifier::extractRootDomain`
**Files:**
- Create: `app/app/Support/SupplierIdentifier.php`
- Test: `app/tests/Unit/Support/SupplierIdentifierTest.php`
- [ ] **Step 1: Написать падающий unit-тест**
Создать файл `app/tests/Unit/Support/SupplierIdentifierTest.php`:
```php
<?php
declare(strict_types=1);
use App\Support\SupplierIdentifier;
use Tests\TestCase;
uses(TestCase::class);
it('extracts root domain from subdomain', function (): void {
expect(SupplierIdentifier::extractRootDomain('krasnoyarsk.carmoney.ru'))->toBe('carmoney.ru');
expect(SupplierIdentifier::extractRootDomain('client.carmoney.ru'))->toBe('carmoney.ru');
expect(SupplierIdentifier::extractRootDomain('next.vashinvestor.ru'))->toBe('vashinvestor.ru');
expect(SupplierIdentifier::extractRootDomain('cabinet.caranga.ru'))->toBe('caranga.ru');
});
it('returns null for already-root domain', function (): void {
expect(SupplierIdentifier::extractRootDomain('carmoney.ru'))->toBeNull();
expect(SupplierIdentifier::extractRootDomain('заложитьптс.рф'))->toBeNull();
});
it('returns null for non-domain identifiers', function (): void {
expect(SupplierIdentifier::extractRootDomain('7800XXXXXXX'))->toBeNull();
expect(SupplierIdentifier::extractRootDomain(''))->toBeNull();
expect(SupplierIdentifier::extractRootDomain(' '))->toBeNull();
expect(SupplierIdentifier::extractRootDomain('TINKOFF'))->toBeNull();
});
```
- [ ] **Step 2: Запустить тест — должен упасть «class not found»**
```bash
cd app && npx --no-install vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php --filter='extracts root domain' 2>&1 | tail -10
```
Альтернатива (если без npx):
```bash
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php 2>&1 | tail -10
```
Expected: ERROR — `Class "App\Support\SupplierIdentifier" not found`
- [ ] **Step 3: Создать минимальную реализацию**
Создать `app/app/Support/SupplierIdentifier.php`:
```php
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
*
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
*/
class SupplierIdentifier
{
/**
* Извлекает корневой домен из identifier'а проекта.
*
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку —
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) — null.
*
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается — у проекта только
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
*/
public static function extractRootDomain(string $identifier): ?string
{
$trimmed = trim($identifier);
if ($trimmed === '') {
return null;
}
// Без точки — не домен (телефон, sms-ключ).
if (! str_contains($trimmed, '.')) {
return null;
}
$parts = explode('.', $trimmed);
if (count($parts) < 3) {
return null; // уже корневой
}
return implode('.', array_slice($parts, -2));
}
}
```
- [ ] **Step 4: Запустить тест — должен пройти**
```bash
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php 2>&1 | tail -10
```
Expected: PASS (3 tests, 11 assertions).
- [ ] **Step 5: Закоммитить**
```bash
git add app/app/Support/SupplierIdentifier.php app/tests/Unit/Support/SupplierIdentifierTest.php
git commit -m "feat(supplier): SupplierIdentifier::extractRootDomain — извлечение корневого домена
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Hook в `SyncSupplierProjectJob` (одиночная синхронизация)
**Files:**
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php:318` (после существующего foreach с insertOrIgnore)
- Test: `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` (добавить 2 теста)
- [ ] **Step 1: Написать падающий feature-тест**
Дописать в конец `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php`:
```php
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
]);
// Предсуществующий supplier_project на корневом домене (например, от другого тенанта
// или от ночной массовой синхронизации) — B2.
$rootSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext-root-b2',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(700101, 700102, 700103);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
// Базовая проверка: 3 линка от 3 supplier_projects по субдомену (B1/B2/B3).
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
// Доп. линк к root-sp должен появиться. Итого 4: 3 субдомен + 1 root.
expect($ownLinks)->toBe(4);
// Конкретно к root-sp.
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists();
expect($rootLink)->toBeTrue();
});
it('site root-level project: does NOT create additional links (no recursion)', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'carmoney.ru',
]);
// Не должен создаваться линк к sp 'ru' — extractRootDomain возвращает null.
SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'ru',
'supplier_external_id' => 'ext-ru-b2',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(700201, 700202, 700203);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($linksCount)->toBe(3); // только 3 от своего root, без рекурсии вверх
});
```
И добавить два use-import'а в начало файла (после строки 11):
```php
use Illuminate\Support\Facades\DB;
```
(Остальные модели уже импортированы выше.)
- [ ] **Step 2: Запустить новые тесты — должны упасть**
```bash
cd app && ./vendor/bin/pest tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php --filter='also links to root-domain' 2>&1 | tail -10
```
Expected: FAIL — `Failed asserting that 3 is 4` (root-link не создаётся, потому что кода ещё нет).
- [ ] **Step 3: Добавить блок auto-root-link в `SyncSupplierProjectJob`**
Открыть `app/app/Jobs/SyncSupplierProjectJob.php`. Сразу **после** существующего блока (после строки 318, перед комментарием `// Mirror the link into the legacy FK columns ...` на строке 320), вставить:
```php
// Auto-link к корневому домену (spec 2026-05-22).
// Если проект на субдомене (krasnoyarsk.carmoney.ru), а у поставщика
// зарегистрирован также корневой sp (carmoney.ru) — добавляем линк и к нему.
if ($project->signal_type === 'site') {
$rootIdentifier = \App\Support\SupplierIdentifier::extractRootDomain(
(string) $project->signal_identifier
);
if ($rootIdentifier !== null) {
$rootSps = \App\Models\SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $rootIdentifier)
->where('signal_type', 'site')
->get();
foreach ($rootSps as $rootSp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
}
}
```
NB: использую FQCN inline (`\App\Support\SupplierIdentifier`, `\App\Models\SupplierProject`), чтобы не возиться с use-операторами в верху файла — если у тебя в файле уже есть `use App\Support\SupplierIdentifier;` и `use App\Models\SupplierProject;`, можно убрать backslash.
- [ ] **Step 4: Запустить тесты — должны пройти**
```bash
cd app && ./vendor/bin/pest tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php 2>&1 | tail -15
```
Expected: PASS (все тесты, включая 2 новых).
- [ ] **Step 5: Закоммитить**
```bash
git add app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
git commit -m "feat(supplier): auto-link субдомен-проекта к корневому supplier_project
В SyncSupplierProjectJob после создания основных линков добавляется
блок: если signal_type=site и identifier — субдомен, ищем sp с
unique_key=root и для каждого insertOrIgnore линк.
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Hook в `SyncSupplierProjectsJob` (массовая ночная синхронизация)
**Files:**
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:427` (после foreach с insertOrIgnore)
- Test: расширить существующий `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` НЕ обязательно — массовый job вызывает ту же логику; покрытие из Task 2 достаточно для самой логики, но для регрессии массового джоба добавим feature-тест.
- [ ] **Step 1: Написать падающий feature-тест для массового джоба**
Найти существующий тест-файл для массового джоба:
```bash
find app/tests -name "SyncSupplierProjectsJob*Test*" 2>&1
```
Если файл существует — дописать тест в него. Если нет — создать `app/tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php` (минимальный новый файл).
Минимальный тест (без полной mock'инфраструктуры — фокус на блоке auto-root):
```php
<?php
declare(strict_types=1);
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('SyncSupplierProjectsJob bulk: subdomain projects получают root-link', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'client.creddy.ru',
]);
$rootSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'creddy.ru',
'supplier_external_id' => 'ext-creddy-root',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('listProjects')->andReturn([]);
$mock->shouldReceive('createProject')->andReturn(800301, 800302, 800303);
});
(new SyncSupplierProjectsJob())->handle(app(SupplierProjectChannel::class));
expect(
DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists()
)->toBeTrue();
});
```
NB: если интерфейс `SupplierProjectChannel` в проекте отличается (другие методы, обязательные параметры) — посмотри как мокается в существующем `SyncSupplierProjectJobTest.php` и адаптируй mock. Главное — добиться, чтобы джоб дошёл до блока `foreach ($groupProjects as $lp) { foreach ($existingSps as $sp) { insertOrIgnore ... } }` (строка 418).
- [ ] **Step 2: Запустить тест — должен упасть**
```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php 2>&1 | tail -10
```
Expected: FAIL — `expect()->toBeTrue()` не сработает, потому что root-link ещё не создаётся.
- [ ] **Step 3: Добавить блок auto-root-link в `SyncSupplierProjectsJob`**
Открыть `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`. Сразу **после** строки 427 (после закрытия двойного `foreach ($groupProjects as $lp) { foreach ($existingSps as $sp) {...} }`), вставить:
```php
// Auto-link к корневому домену (spec 2026-05-22).
// groupProjects шарят один и тот же identifier — вычисляем root один раз.
$firstProject = $groupProjects[0] ?? null;
if ($firstProject !== null && $firstProject->signal_type === 'site') {
$rootIdentifier = \App\Support\SupplierIdentifier::extractRootDomain(
(string) $firstProject->signal_identifier
);
if ($rootIdentifier !== null) {
$rootSps = \App\Models\SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $rootIdentifier)
->where('signal_type', 'site')
->get();
foreach ($groupProjects as $lp) {
foreach ($rootSps as $rootSp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $lp->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
}
}
}
```
- [ ] **Step 4: Запустить тест — должен пройти**
```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php 2>&1 | tail -10
```
Expected: PASS.
- [ ] **Step 5: Закоммитить**
```bash
git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php
git commit -m "feat(supplier): auto-link к root-домену в массовой ночной синхронизации
Аналогичный блок в SyncSupplierProjectsJob после двойного foreach
insertOrIgnore основных линков (строка 427).
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Артизан-команда `supplier:backfill-root-links`
**Files:**
- Create: `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php`
- Test: `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php`
- [ ] **Step 1: Написать падающий feature-тест**
Создать `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php`:
```php
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('backfill: добавляет root-link для проекта-субдомена с уже-существующими линками', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
]);
$subdomainSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'krasnoyarsk.carmoney.ru',
'supplier_external_id' => 'ext-sub',
'current_limit' => 100,
'sync_status' => 'ok',
]);
$rootSp = SupplierProject::create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext-root',
'current_limit' => 100,
'sync_status' => 'ok',
]);
// Существующий линк только к субдомену.
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $subdomainSp->id,
'platform' => 'B2',
'subject_code' => null,
]);
// Запуск команды.
$exitCode = \Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
expect($exitCode)->toBe(0);
// Должен появиться второй линк — к root-sp.
expect(
DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists()
)->toBeTrue();
});
it('backfill: idempotent — повторный прогон ничего не добавляет', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'client.carmoney.ru',
]);
$subSp = SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'client.carmoney.ru',
'supplier_external_id' => 'ext1', 'current_limit' => 100, 'sync_status' => 'ok',
]);
$rootSp = SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'carmoney.ru',
'supplier_external_id' => 'ext2', 'current_limit' => 100, 'sync_status' => 'ok',
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
'platform' => 'B2', 'subject_code' => null,
]);
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
$linksAfterFirstRun = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
$linksAfterSecondRun = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($linksAfterFirstRun)->toBe(2); // sub + root
expect($linksAfterSecondRun)->toBe(2); // никаких новых
});
it('backfill --dry-run: ничего не пишет в БД', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'next.vashinvestor.ru',
]);
$subSp = SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'next.vashinvestor.ru',
'supplier_external_id' => 'extn1', 'current_limit' => 100, 'sync_status' => 'ok',
]);
SupplierProject::create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru',
'supplier_external_id' => 'extn2', 'current_limit' => 100, 'sync_status' => 'ok',
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
'platform' => 'B2', 'subject_code' => null,
]);
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links', ['--dry-run' => true]);
$count = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($count)->toBe(1); // только исходный линк, root не добавился
});
```
- [ ] **Step 2: Запустить тест — должен упасть**
```bash
cd app && ./vendor/bin/pest tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -10
```
Expected: FAIL — `Command "supplier:backfill-root-links" is not defined.`
- [ ] **Step 3: Реализовать команду**
Создать `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php`:
```php
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Support\SupplierIdentifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Однократный back-fill: для каждого активного site-проекта с identifier-субдоменом —
* добавить линки к supplier_projects на корневом домене (если те существуют).
*
* Идемпотентна (insertOrIgnore по UNIQUE (project_id, supplier_project_id)).
*
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
*/
class BackfillRootSupplierLinksCommand extends Command
{
protected $signature = 'supplier:backfill-root-links {--dry-run : показать что бы добавилось, не писать в БД}';
protected $description = 'Back-fill линков project_supplier_links к корневому домену для проектов-субдоменов';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$scanned = 0;
$added = 0;
$skippedAlreadyRoot = 0;
$skippedNoRootSp = 0;
$projects = Project::query()
->on('pgsql_supplier')
->where('signal_type', 'site')
->whereExists(function ($q): void {
$q->select(DB::raw(1))
->from('project_supplier_links')
->whereColumn('project_supplier_links.project_id', 'projects.id');
})
->get(['id', 'signal_identifier']);
foreach ($projects as $project) {
$scanned++;
$root = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
if ($root === null) {
$skippedAlreadyRoot++;
continue;
}
$rootSps = SupplierProject::on('pgsql_supplier')
->where('unique_key', $root)
->where('signal_type', 'site')
->get();
if ($rootSps->isEmpty()) {
$skippedNoRootSp++;
continue;
}
foreach ($rootSps as $rootSp) {
$alreadyExists = DB::connection('pgsql_supplier')
->table('project_supplier_links')
->where('project_id', $project->id)
->where('supplier_project_id', $rootSp->id)
->exists();
if ($alreadyExists) {
continue;
}
if (! $dryRun) {
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
$added++;
}
}
$this->info(sprintf(
'%sScanned: %d / Added: %d / Skipped (already root): %d / Skipped (no root sp): %d',
$dryRun ? '[DRY-RUN] ' : '',
$scanned,
$added,
$skippedAlreadyRoot,
$skippedNoRootSp,
));
return self::SUCCESS;
}
}
```
NB: если auto-discovery команд не работает (не Laravel-default) — проверить регистрацию в `app/app/Console/Kernel.php` или `app/bootstrap/app.php`. Команда из `app/app/Console/Commands/` автоматически подбирается в стандартной конфигурации Laravel 11+.
- [ ] **Step 4: Запустить тест — должен пройти**
```bash
cd app && ./vendor/bin/pest tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -15
```
Expected: PASS (3 теста).
- [ ] **Step 5: Закоммитить**
```bash
git add app/app/Console/Commands/BackfillRootSupplierLinksCommand.php app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php
git commit -m "feat(supplier): артизан-команда supplier:backfill-root-links
Однократный back-fill для уже существующих проектов: для каждого
site-проекта с identifier-субдоменом добавляет линки к корневым sp,
если те уже есть в supplier_projects. Идемпотентна.
--dry-run для предварительного просмотра.
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Финальная регрессия фичи root-link
**Files:** ничего не создаём, только проверяем.
- [ ] **Step 1: Запустить тесты по затронутым областям**
```bash
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -20
```
Expected: всё PASS.
- [ ] **Step 2: Запустить более широкую регрессию по supplier-области**
```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier tests/Feature/Plan5 2>&1 | tail -20
```
Expected: всё PASS. Если что-то падает — исправлять прежде чем двигаться к Phase B/C/D.
- [ ] **Step 3: Type-check + Larastan baseline (быстрая проверка)**
```bash
cd app && composer stan 2>&1 | tail -10
```
Expected: 0 ошибок поверх baseline.
- [ ] **Step 4: Pint code-style**
```bash
cd app && composer pint -- --test 2>&1 | tail -10
```
Если есть нарушения — `composer pint` (без `-- --test`), затем `git add -u && git commit --amend --no-edit`.
Expected: clean.
---
## Self-Review (проводится после написания плана автором)
**Spec coverage:**
- §4.1 утилита extractRootDomain → Task 1 ✓
- §4.2 hook в SyncSupplierProjectJob → Task 2 ✓
- §4.2 hook в SyncSupplierProjectsJob (массовый) → Task 3 ✓
- §4.3 артизан-команда + --dry-run + идемпотентность → Task 4 ✓
- §5.1 unit-тесты утилиты → Task 1 Step 1 ✓
- §5.2 feature subdomain → 2 link sets → Task 2 Step 1 ✓
- §5.3 root project → только свои линки → Task 2 Step 1 (второй it) ✓
- §5.4 call/sms → root-логика skip → covered by Task 1 (unit tests на не-домены) + по факту не идёт через if signal_type === 'site' в Task 2/3 ✓
- §5.5 идемпотентность + dry-run → Task 4 Step 1 ✓
- §6 деплой / прод-backfill — Phase E плана выше, вне этого плана
- §7 rollback — readiness описана в спеке, не код
**Placeholder scan:** ни одного TBD / TODO / «implement later» / «similar to». Везде показан код. ✓
**Type consistency:** `extractRootDomain` возвращает `?string` — везде нулл-чек. `signal_type === 'site'` — string match. `unique_key`/`platform`/`signal_type` — поля `supplier_projects` (по schema.sql). `DB_CONNECTION = 'pgsql_supplier'` — константа джоба, явно использую в каждом insertOrIgnore. ✓
@@ -0,0 +1,235 @@
# Регистрация: подтверждение email кодом + обязательный телефон — дизайн
**Дата:** 2026-05-21
**Контекст:** пилот. Решение держим простым, без переусложнения (YAGNI).
**Статус:** утверждён заказчиком, готов к плану реализации.
## 1. Проблема
Текущая регистрация (`POST /api/auth/register`) принимает только email + пароль +
2 click-wrap'а (оферта, ПДн) и сразу создаёт аккаунт. Два пробела:
1. **Нет подтверждения владения email** — любой может зарегистрироваться на чужой
или несуществующий адрес.
2. **Нет телефона** — невозможно связаться с пользователем.
## 2. Цели
- Доказать владение email **до** создания аккаунта (6-значный код на почту).
- Сделать телефон **обязательным** при регистрации, ввод по маске `+7 (XXX) XXX-XX-XX`.
- Реальная доставка писем через **Яндекс SMTP**.
## 3. Не-цели (явно вне scope пилота)
- SMS-подтверждение телефона (нет SMS-провайдера) — только сбор номера, проверка формата.
- Новые таблицы БД / правка `db/schema.sql` — используем серверную сессию (паттерн 2FA).
- Принуждение существующих пользователей (демо-аккаунты) добавить телефон или
переподтвердить почту. Новые требования — **только для новых регистраций**.
- DEV-показ кода в ответе/логе приложения. Код доставляется **только письмом**.
- Резолв телефон→регион при регистрации (`PhonePrefixService` используется в другом месте).
## 4. Текущее состояние кода (что уже есть)
- `app/app/Http/Controllers/Api/AuthController.php``register()` (одношаговый).
- `app/app/Http/Requests/Auth/RegisterRequest.php` — валидация (email/password/2 чекбокса).
- `app/resources/js/views/auth/RegisterView.vue` — форма (email/пароль/strength/2 чекбокса).
- `app/resources/js/stores/auth.ts``register(payload)` + `authApi.register`.
- `db/schema.sql`: таблица `users` уже имеет колонки `phone VARCHAR(20)` и
`email_verified_at TIMESTAMPTZ`; модель `User` уже приводит тип `email_verified_at` (cast) и
имеет `phone` в `$fillable`. **Схему менять не нужно.**
- Существует неиспользуемая таблица `email_verifications` (с `user_id NOT NULL`) —
она спроектирована под верификацию **существующего** пользователя и **не подходит**
под «код до создания аккаунта». Оставляем как есть (не трогаем).
- Паттерн «отложенного» состояния в сессии уже применяется: 2FA-логин кладёт
`auth.pending_user_id` в session между `login` и `2fa/verify`. Повторяем этот же приём.
- Mail-инфраструктура: есть Mailable'ы (напр. `SuspiciousLoginNotification`),
отправка через `Mail::to(...)->send(...)`.
## 5. Выбранный подход
**Хранение незавершённой регистрации — серверная сессия** (не новая таблица).
Причина: не требует изменения схемы/RLS, переживает между двумя запросами
(сессионная cookie выдаётся и гостю), консистентно с уже существующим 2FA-pending.
Минус (теряется при смене браузера/вкладки) для пилота приемлем.
## 6. Поток регистрации
```
Шаг 1 (форма) POST /api/auth/register/start
вход: email, phone, password, accept_offer, accept_pdn
проверки: email формат+уникальность, пароль (текущие правила),
phone формат, оба чекбокса
действие: генерируем 6-значный код, кладём pending в session,
шлём письмо с кодом на email
ответ: 200 { message, email }
Шаг 2 (ввод кода) POST /api/auth/register/verify
вход: code
проверки: pending есть в session; не истёк; попыток < 5; код совпал
действие: создаём User (phone нормализован, email_verified_at = now()),
Auth::login, session()->regenerate(), чистим pending
ответ: 201 { user, requires_2fa: false }
Повторная отправка POST /api/auth/register/resend
действие: перегенерировать код, обновить expires_at, переотправить письмо
ограничение: cooldown 60 сек между отправками; не чаще лимита start
ответ: 200 { message }
```
## 7. Backend — детали
### 7.1 Эндпоинты и роуты (`app/routes/web.php`, группа `/api/auth`)
- Заменяем `POST /register` на:
- `POST /register/start``AuthController@registerStart`
- `POST /register/verify``AuthController@registerVerify`
- `POST /register/resend``AuthController@registerResend`
- Все три — публичные (как был `register`).
- Старый одношаговый `register()` удаляем (единственный потребитель — фронт, его обновляем).
### 7.2 Form Requests
- `RegisterStartRequest` (на базе текущего `RegisterRequest` + `HasPasswordRules`):
- `email`: required, string, email, max:255, **unique(users,email)**.
- `password`: текущие `passwordRules()`.
- `phone`: required, string; после нормализации обязан матчить `^7\d{10}$`.
- `accept_offer`: required, accepted.
- `accept_pdn`: required, accepted.
- Сообщения — RU, в стиле текущего `RegisterRequest::messages()`.
- `RegisterVerifyRequest`:
- `code`: required, string, regex `^\d{6}$`.
### 7.3 Нормализация телефона
- Хелпер (метод/Service): убрать всё кроме цифр; ведущую `8``7`; `+7``7`;
результат обязан быть `7` + 10 цифр (`^7\d{10}$`), иначе ошибка валидации.
- Хранение в БД: нормализованный `7XXXXXXXXXX` (консистентно с `PhonePrefixService`).
### 7.4 Структура pending в session (ключ `registration.pending`)
```
{
email, password_hash, // password хешируем сразу (Hash::make) — не храним plaintext
phone, // нормализованный 7XXXXXXXXXX
accept_offer, accept_pdn,
code_hash, // sha256(code) — не храним сам код
expires_at, // now + 15 мин
attempts, // счётчик неверных вводов, старт 0, лимит 5
send_count, last_sent_at // для cooldown/лимита отправок
}
```
### 7.5 Код подтверждения
- 6 цифр, `str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT)`.
- Срок жизни 15 минут.
- Сверка: `hash_equals(code_hash, hash('sha256', input))`.
- Лимит неверных вводов — 5; при превышении pending инвалидируется (нужно начать заново).
### 7.6 Rate-limiting (защита от спама писем)
- `register/start`: ключ `email|ip`, лимит ~5 запросов/час (через `RateLimiter`).
- `register/resend`: cooldown 60 сек между отправками + общий лимит как у start.
- `register/verify`: лимит попыток в pending (5) + опц. `RateLimiter` по ip.
### 7.7 Письмо
- Новый Mailable `RegisterEmailVerificationCode` (зеркалит существующие Mailable'ы):
- Тема: «Код подтверждения регистрации — Лидерра».
- Тело (blade/markdown, бренд «Лидерра»): код крупно + «срок действия 15 минут,
если вы не регистрировались — проигнорируйте письмо».
- Получатель — email из pending.
### 7.8 Создание пользователя (на verify)
Зеркалит текущий `register()`:
- `tenant_id` = `Tenant::first()->id` (MVP attach; если нет тенанта — 503).
- `first_name='Новый'`, `last_name='Пользователь'` (как сейчас; меняются в профиле).
- `phone` = нормализованный.
- `email_verified_at` = `now()` (почта доказана кодом).
- `is_active=true`, `totp_enabled=false`.
- `Auth::login` + `session()->regenerate()` + очистка pending.
## 8. Frontend — детали
### 8.1 `RegisterView.vue` — двухшаговый
- **Шаг 1 (форма):** email, **phone (маска `+7 (XXX) XXX-XX-XX`)**, пароль (+индикатор
силы, как сейчас), 2 чекбокса. Кнопка «Получить код». `canSubmit` учитывает заполненный
и валидный по маске телефон.
- **Шаг 2 (код):** поле на 6 цифр, кнопка «Подтвердить и создать аккаунт»,
ссылка «Отправить код повторно» (с обратным отсчётом cooldown 60 сек),
ссылка «Изменить данные» (вернуться к шагу 1).
- Ошибки backend (`extractValidationErrors`) показываются под полями;
email-unique и формат телефона — на шаге 1, неверный/истёкший код — на шаге 2.
### 8.2 Маска телефона
- Без новой тяжёлой зависимости: маленький dependency-free форматтер/composable
(`formatPhone(digits) → "+7 (XXX) XXX-XX-XX"`, хранит нормализованные цифры
`7XXXXXXXXXX`). Тестируется юнит-тестом.
### 8.3 `auth.ts` store + `api`
- `register(payload)` заменяется на: `registerStart(payload)`, `registerVerify({code})`,
`registerResend()`; соответствующие методы в `authApi`.
- На успешный verify — поведение как сейчас: переход на `/dashboard`
(`requires_2fa` всегда false при регистрации).
## 9. Конфигурация почты (операционный шаг, не в git)
Яндекс SMTP в `app/.env` (значения — от заказчика, **в репозиторий не коммитим**,
gitleaks в pre-commit защищает):
```
MAIL_MAILER=smtp
MAIL_HOST=smtp.yandex.ru
MAIL_PORT=465
MAIL_ENCRYPTION=ssl # либо 587 + tls
MAIL_USERNAME=<ящик@yandex.ru>
MAIL_PASSWORD=<пароль приложения Яндекса>
MAIL_FROM_ADDRESS=<тот же ящик@yandex.ru> # Яндекс требует совпадения From с авторизованным ящиком
MAIL_FROM_NAME="Лидерра"
```
NB: на Яндексе нужен **пароль приложения** (не основной пароль), SMTP должен быть
включён в настройках ящика; `MAIL_FROM_ADDRESS` обязан совпадать с `MAIL_USERNAME`.
## 10. Тестирование
### 10.1 Pest (Feature) — `Mail::fake()`
- `register/start` валидный → 200, `Mail::assertSent(RegisterEmailVerificationCode)` на email, pending в session.
- start: дубль email → 422; плохой phone → 422; слабый пароль → 422; без чекбоксов → 422.
- start: throttle/cooldown.
- `register/verify` верный код → 201, user создан с `email_verified_at != null` и нормализованным `phone`, залогинен.
- verify: неверный код → 422 + инкремент attempts; после 5 → pending инвалидирован; истёкший код → 422; нет pending → 422/409.
- `register/resend` → новый код, cooldown enforced.
- Обновить существующие тесты старого `register` (заменены новым потоком).
### 10.2 Vitest
- `RegisterView`: переход шаг1→шаг2, форматирование маски телефона, `canSubmit`-гейтинг,
показ ошибок, cooldown повторной отправки.
- Юнит-тест форматтера телефона (нормализация `8…`/`+7…`/`7…`).
## 11. Затрагиваемые файлы (ориентир)
**Backend:** `AuthController.php`, новые `RegisterStartRequest.php` / `RegisterVerifyRequest.php`,
новый `RegisterEmailVerificationCode` Mailable + blade-шаблон, `routes/web.php`,
(опц.) маленький phone-normalizer service/helper. Существующие register-тесты.
**Frontend:** `RegisterView.vue`, `stores/auth.ts`, `api` client, новый phone-format composable,
соответствующие spec-файлы (Vitest) + `RegisterView.story.vue` (при необходимости).
**Конфиг:** `app/.env` (Яндекс SMTP — вне git).
**Схема БД:** без изменений.
## 12. Открытые риски
- Реальная доставка зависит от корректных SMTP-доступа Яндекса (логин + пароль приложения,
включённый SMTP, совпадение From). Проверяется живой отправкой при настройке.
- Сессионное хранение pending: при смене браузера/долгом простое регистрацию надо
начать заново — приемлемо для пилота.
@@ -0,0 +1,141 @@
# Тестовый деплой портала Лидерра в Yandex Cloud — дизайн
**Дата:** 2026-05-21
**Статус:** черновик дизайна (brainstorming) → ожидает вычитки заказчиком → writing-plans
**Автор:** Claude + Дмитрий
**Тип:** инфраструктура / деплой (не фича приложения)
## 1. Цель
Поднять рабочую копию портала Лидерры в интернете по стабильному адресу с настоящим
HTTPS, чтобы её могли открывать **только заказчик (Дмитрий) и Claude** для сквозного
ручного тестирования. Это **тестовое/staging-окружение**, не продакшен: без юр.лица,
без реальной почты, без SSO, под снос в любой момент.
## 2. Что НЕ входит (YAGNI / границы)
- ❌ Yandex 360 SSO (корпоративный вход админов) — ждёт Б-1 (ООО).
- ❌ Реальный landing, реальная почта (Unisender Go), Sentry-мониторинг, бэкапы,
автодеплой из GitHub (CI/CD).
- ❌ Управляемые БД/Redis Yandex (Managed PostgreSQL/Redis) — это для будущего прода.
- ❌ Перенос текущей dev-базы — на сервере свежие демо-данные.
- ❌ Публичный доступ для чужих тестеров (для этого понадобились бы реальная почта,
закрытие админки, реальная изоляция — отдельный этап).
## 3. Решения, принятые в brainstorming
| Развилка | Выбор |
|---|---|
| Где хостить | Отдельный Linux-сервер в **Yandex Cloud** (вариант A — всё на одной VM) |
| Аккаунт YC | Заводится с нуля заказчиком (создан 21.05.2026: облако `cloud-sasha261185`, каталог `default`); ожидает привязки платёжного аккаунта + грант 60 дней |
| Адрес | **Свой домен** (поддомен вида `test.<домен>`) + настоящий HTTPS (Let's Encrypt) |
| Кто настраивает сервер | **Claude по SSH** с dev-машины; заказчик даёт доступ (вставляет публичный ключ при создании VM) |
| Архитектура | Вариант A — один сервер, нативная установка (nginx + PHP-FPM + PostgreSQL + Redis), без Docker, без управляемых сервисов |
## 4. Архитектура сервера
Одна VM (Ubuntu LTS, ~2 vCPU / 24 ГБ, диск 15–20 ГБ SSD, зона `ru-central1-a`):
```
интернет
ваш домен (test.…) ──DNS A-запись──► публичный IP VM
┌───────┴─ nginx (HTTPS, Let's Encrypt авто-продление) ──────────┐
│ • HTTP Basic Auth на весь сайт (пускает только нас двоих) │
│ — кроме пути webhook поставщика (защищён HMAC-подписью) │
└───────┬─────────────────────────────────────────────────────────┘
PHP-FPM 8.3 ← код портала + собранный фронтенд (public/build)
┌───────┼─────────┐
PostgreSQL 16 Redis 7 (на этой же машине)
systemd-службы: queue worker (queue:work redis) + scheduler
(php artisan schedule:run по cron) — переживают перезагрузку
```
**Поток выкладки кода:**
1. Сервер тянет код из приватного репо `CoralMinister/lidpotok` по **read-only deploy-key**
(генерируется на сервере, заказчик добавляет в GitHub → Deploy keys).
2. `composer install --no-dev --optimize-autoloader`.
3. **Фронтенд собирается на dev-машине** (`npm --prefix app run build`) и заливается
(`app/public/build`) на сервер — чтобы не держать Node и не упираться в RAM при сборке.
4. Накат схемы БД (`db/schema.sql` v8.27) + демо-данные (seed + 5 учёток).
5. `php artisan config:cache route:cache view:cache`.
**Обновление новой версии** (когда понадобится) — одна идемпотентная команда/скрипт:
`git pull` → composer → залить новый build → migrate → пересобрать кэши → перезапустить
php-fpm + queue. Оформлю как `deploy.sh` на сервере + короткую инструкцию.
## 5. Безопасность теста
1. **Edge-дверь:** nginx HTTP Basic Auth на весь сайт (один общий логин/пароль для нас
двоих; хранится в `/etc/nginx/.htpasswd`). Посторонние и поисковики сайт не видят.
Исключение — путь приёма лидов от поставщика (webhook, защищён HMAC), чтобы при
желании протестировать живой приём от `crm.bp-gr.ru`.
2. **Админка:** middleware `EnsureSaasAdmin` в проде отдаёт 503 (ждёт Yandex SSO).
Добавляется **минимальный временный флаг** `SAAS_ADMIN_TEST_BYPASS` (config
`app.saas_admin_test_bypass`, default `false`): когда `true` — middleware пропускает.
Включается только на тест-сервере, помечен «убрать после внедрения реального SSO».
Правка в коде — небольшая, закоммичена, по умолчанию выключена → прод не затронут.
3. **Боевой режим без утечек:** `APP_ENV=production`, `APP_DEBUG=false`.
4. **Реальная изоляция компаний (RLS):** на сервере подключаются настоящие роли БД
(`db/00_create_roles.sql` + `db/02_grants.sql`; приложение ходит как `crm_app_user`,
джобы — как `crm_supplier_worker` BYPASSRLS). В отличие от dev (postgres-суперюзер,
RLS обходится) — изоляция реально работает.
- ⚠️ **Риск:** RLS включается «вживую» впервые. Возможен запрос, работавший под
суперюзером и падающий под RLS. Реакция: чиню точечно либо временно ослабляю роль.
Считается полезным для теста.
5. **SSH:** доступ по ключу (пароли отключены); порт 22 в группе безопасности по
возможности ограничить IP dev-машины + заказчика. Открыты порты 80/443/22.
6. **Почта:** `MAIL_MAILER=log` (письма в лог, не на ящик) — не нужны, заходим под
готовыми демо-учётками.
## 6. Данные
Демо-набор как на dev: 5 изолированных компаний, входы `admin@demo.local` +
`manager1..4@demo.local`, пароль у всех `password`. Демо-данные — стираемые.
## 7. Разделение работ
**Заказчик (через веб-интерфейсы, по инструкции Claude):**
1. Завершить регистрацию YC + привязать карту + забрать грант 60 дней.
2. Создать VM (Ubuntu), вставить публичный SSH-ключ Claude.
3. Сообщить публичный IP машины.
4. Прописать у домена A-запись `test.<домен>` → IP.
5. Добавить read-only deploy-key в GitHub-репо.
6. Придумать общий логин/пароль «двери» сайта.
**Claude (по SSH, сам):** вся установка/настройка сервера, выкладка кода, сборка-загрузка
фронтенда, схема БД + демо-данные, HTTPS, systemd-службы, проверка (портал открывается,
логин работает, изоляция компаний работает), `deploy.sh` + инструкция обновления.
**Доступ Claude:** только IP сервера + SSH по ключу, который Claude генерирует сам.
Паролей/карт заказчика Claude не получает.
## 8. Стоимость и жизненный цикл
- ~10001500 ₽/мес за VM (2 vCPU / 2–4 ГБ); грант 60 дней + до 10 000 ₽ — вероятно,
первый период бесплатно.
- Домен — ~200–1500 ₽/год (если ещё нет).
- Тест не нужен → VM остановить/удалить → оплата прекращается.
## 9. Критерии готовности (Definition of Done)
- По адресу `https://test.<домен>` открывается портал с валидным HTTPS-замочком.
- Сайт под Basic Auth (посторонний без логина не входит).
- Вход `admin@demo.local` / `password` работает; видны 4 демо-проекта.
- `manager1@demo.local` видит только свою компанию (RLS работает).
- Админка `/admin/*` доступна (через временный флаг).
- queue worker + scheduler работают как службы, переживают перезагрузку VM.
- Есть `deploy.sh` + инструкция «как выложить новую версию».
## 10. Открытые мелочи (решим в плане)
- Точный размер VM (2 ГБ vs 4 ГБ) — зависит от того, собираем ли фронт на сервере
(план: собираем на dev → 2 ГБ хватит).
- Точный путь webhook-исключения в nginx — уточнить по `routes/`.
- Имя поддомена и сам домен — от заказчика.
@@ -0,0 +1,186 @@
# Автолинковка проекта-субдомена к корневому домену поставщика
**Дата:** 2026-05-22
**Статус:** approved (Дмитрий, через брейнсторминг 22.05)
**Связано:** [PR будет добавлен после реализации]
## 1. Контекст
Поставщик `crm.bp-gr.ru` шлёт лиды через webhook с полем `raw_payload.project`, например:
- `"B2_заявка carmoney.ru/"` — корневой домен
- `"B1_Платежи cabinet.caranga.ru/login"` — субдомен внутри текста
- `"B3_next.vashinvestor.ru"` — субдомен
- `"B1_7800XXXXXXX"` — телефон (call-сигнал)
`parseProjectField()` в [RouteSupplierLeadJob.php:175](../../../app/app/Jobs/RouteSupplierLeadJob.php#L175) извлекает чистый identifier (`carmoney.ru` / `cabinet.caranga.ru` / `next.vashinvestor.ru` / телефон), под него `resolveOrStub()` находит/создаёт `supplier_projects.unique_key`, и `LeadRouter::matchEligibleProjects()` ищет привязанные к этому `supplier_project.id` тенант-проекты через таблицу `project_supplier_links`.
Текущий гэп: поставщик иногда шлёт **корневой домен** (например, `carmoney.ru/`), а тенант подписан только на **субдомены** (`krasnoyarsk.carmoney.ru` и `client.carmoney.ru`). `unique_key`'и не совпадают → ни одной матчинг-связки → `deals_created_count = 0` тихо, без инцидента.
Аналитика 22.05:
- 99 лидов с `carmoney.ru` (root) за 24 часа → 0 сделок
- 7 silent-no-deal из последних 13 лидов после починки старого ретрай-шторма
## 2. Цели
1. Подписчик на любой субдомен крупного сайта автоматически получает также лиды с корневого домена этого сайта.
2. Не задеть существующие линки/маршрутизацию.
3. Решить и для уже существующих проектов (back-fill).
## 3. Что не закрывает (non-goals)
- **348 потерянных лидов 21.05–22.05 утра** до создания проектов тенанта 2 — отдельный back-fill (Phase E плана).
- **Случай, когда подписчика на бренд нет вообще.** Если никто не подписан ни на корень, ни на субдомен — silent skip остаётся ожидаемым поведением.
- **Реверс-кейс:** клиент с корневым `carmoney.ru` НЕ получает автоматически субдомены. Явная подписка — явный матч (option C из брейнсторминга).
- **`incidents_log` observability** — отдельная фаза C плана.
- **Реакция на появление корневого `supplier_project` ПОСЛЕ создания проекта-субдомена** — в данных она уже отработана разовым back-fill'ом; future-proof механизм (триггер на upsert supplier_projects) — out of scope.
## 4. Архитектура
### 4.1. Утилита извлечения корня
Новый класс `App\Support\SupplierIdentifier::extractRootDomain(string $identifier): ?string`.
Правило:
1. Если строка не похожа на домен (нет точки или есть только цифры/спецсимволы) — вернуть `null`.
2. Если в идентификаторе ≤ 2 сегментов через точку — он уже корневой, вернуть `null`.
3. Иначе — `implode('.', array_slice(explode('.', $identifier), -2))`.
Таблица:
| вход | выход | объяснение |
|------|-------|------------|
| `krasnoyarsk.carmoney.ru` | `carmoney.ru` | 3 сегмента → 2 последних |
| `next.vashinvestor.ru` | `vashinvestor.ru` | 3 → 2 |
| `cabinet.caranga.ru` | `caranga.ru` | 3 → 2 |
| `client.carmoney.ru` | `carmoney.ru` | 3 → 2 |
| `carmoney.ru` | `null` | уже корень |
| `заложитьптс.рф` | `null` | уже корень, кириллица допустима |
| `7800XXXXXXX` | `null` | не домен (нет точки) |
| `` | `null` | пустая |
Не поддерживается public-suffix-list (т.е. `*.co.uk` обработается как `*.co.uk` → root `co.uk` — некорректно), но у нас таких доменов нет — фиксируем явно и не усложняем.
### 4.2. Точка врезки в синхронизацию
После создания основных линков для проекта (в обоих синк-джобах) добавляется блок «auto-root-link»:
**В [SyncSupplierProjectJob.php](../../../app/app/Jobs/SyncSupplierProjectJob.php) после строки 318 (внутри метода `handle`)**:
```php
// Auto-link к корневому домену для проектов-субдоменов (spec 2026-05-22).
if ($project->signal_type === 'site') {
$rootIdentifier = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
if ($rootIdentifier !== null) {
$rootSps = SupplierProject::query()
->where('unique_key', $rootIdentifier)
->where('signal_type', 'site')
->get();
foreach ($rootSps as $rootSp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $rootSp->id,
'platform' => $rootSp->platform,
'subject_code' => null,
]);
}
}
}
```
**Аналогичный блок в [SyncSupplierProjectsJob.php:420](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L420)** — массовая ночная синхронизация — после существующего блока вставки.
Хук НЕ нужен в:
- `LeadRouter` — он остаётся декларативным: ищет по `project_supplier_links`, что лежит.
- `RouteSupplierLeadJob` — не трогаем (только зависит от router'а).
- `DeleteSupplierProjectJob` — удаление линка по `supplier_project_id` каскадом снесёт и автодобавленный root-линк, что корректно.
### 4.3. Однократный back-fill уже существующих проектов
Артизан-команда `App\Console\Commands\BackfillRootSupplierLinks`:
```bash
php artisan supplier:backfill-root-links [--dry-run]
```
Алгоритм:
1. Выбрать все активные проекты с `signal_type='site'`, у которых есть хотя бы один линк (`project_supplier_links`).
2. Для каждого вычислить `rootIdentifier = extractRootDomain($project->signal_identifier)`.
3. Если `null` → пропуск.
4. Иначе — найти все `supplier_projects` с `unique_key = rootIdentifier`, `signal_type='site'`.
5. Для каждого — `insertOrIgnore` в `project_supplier_links`.
6. Логировать счётчики: `projects_scanned / links_added / projects_skipped_root / projects_skipped_no_root_sp`.
Идемпотентна (insertOrIgnore). Можно запускать многократно без эффекта на уже добавленные.
`--dry-run` режим — печатает, что бы добавил, но не пишет в БД.
## 5. Тесты (TDD)
### 5.1. Unit: `SupplierIdentifier::extractRootDomain`
Покрытие 7 кейсов из таблицы §4.1 + null/пустая строка + строка с пробелами.
Файл: `app/tests/Unit/Support/SupplierIdentifierTest.php`.
### 5.2. Feature: `SyncSupplierProjectJob` создаёт root-link
Сетап: тенант + проект `site krasnoyarsk.carmoney.ru` + supplier_projects `carmoney.ru` (B1/B2/B3) + `krasnoyarsk.carmoney.ru` (B1/B2/B3) уже синкнуты.
Ожидание: после `SyncSupplierProjectJob::handle()` в `project_supplier_links` есть 6 строк — по одной для каждого из 6 supplier_projects.
Файл: дополняет `app/tests/Feature/Jobs/SyncSupplierProjectJobTest.php` (или новый).
### 5.3. Feature: корневой проект → только свои линки, без рекурсии
Сетап: проект `site carmoney.ru` (root) + только sp `carmoney.ru` (B1/B2/B3).
Ожидание: 3 линка, не пытается рекурсивно искать sp `ru`.
### 5.4. Feature: call/sms проект → root-логика skip
Сетап: проект `call 7800XXXXXXX` + sp с тем же ключом.
Ожидание: основные линки создаются, root-блок не активируется (extractRootDomain вернёт null).
### 5.5. Feature: артизан-команда `supplier:backfill-root-links`
Сетап: проект `site krasnoyarsk.carmoney.ru` с одним линком к `krasnoyarsk.carmoney.ru` (B2). Существует sp `carmoney.ru` (B2).
Ожидание:
1. `--dry-run` → ничего не пишет, выводит «added: 1»
2. Реальный прогон → добавляется 1 строка в `project_supplier_links` (к sp `carmoney.ru`)
3. Повторный прогон → ничего не добавляется, выводит «added: 0»
## 6. Деплой
Поэтапно (см. план Phase D-E):
1. Деплой кода через копирование (как принято — ПИЛОТ.md §2).
2. На проде: `php artisan supplier:backfill-root-links --dry-run` → визуальная проверка ожидаемых счётчиков.
3. Реальный прогон: `php artisan supplier:backfill-root-links` → создаются root-линки для уже существующих проектов.
4. Верификация через `psql`: для одного проекта тенанта 2 (например, project 87 `krasnoyarsk.carmoney.ru`) убедиться, что `project_supplier_links` содержит линки к sp `carmoney.ru` (37/38/39) и `krasnoyarsk.carmoney.ru` (248/249/250).
## 7. Риск и откат
**Риск:** низкий.
- Чистая addition: только добавляем `project_supplier_links`, ничего не удаляем и не меняем.
- `insertOrIgnore` исключает дубликаты по PRIMARY KEY (project_id, supplier_project_id).
- Роутер не меняется — логика декларативная.
**Откат:**
- Если что-то пошло не так после деплоя кода — `git revert` коммита, копирование старого файла обратно на сервер.
- Если backfill-команда создала лишнее — точечный SQL `DELETE FROM project_supplier_links WHERE created_at > 'момент-запуска' AND supplier_project_id IN (SELECT id FROM supplier_projects WHERE unique_key NOT LIKE '%.%.%')`. `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` уже есть в схеме (`db/schema.sql` определение `project_supplier_links`).
## 8. Out-of-spec на будущее (не сейчас)
- Public-suffix-list (PSL) для корректной обработки `.co.uk` / `.com.au` и т.п.
- Триггер «появился корневой supplier_project → добавить линки от всех проектов-субдоменов» — если будет регрессия с появлением новых корней.
- UI-индикация на странице проекта: «также получает лиды с корневого `carmoney.ru`».
+34 -6
View File
@@ -9,7 +9,7 @@
**перепроверять реальной командой**, не доверять снимку вслепую.
- Обновляется по команде заказчика **«обнови эталон»**.
**Снимок снят:** 21.05.2026 (день, после фичи «удаление проектов вместо архива + дедуп источника + человеческие ошибки» — 10 коммитов FF в main; volatile §1–§4 пересверены).
**Снимок снят:** 21.05.2026 (вечер, после поднятия **тест-сервера Yandex Cloud** и настройки всех 3 каналов миграции с поставщиком на нём; volatile §1–§4 пересверены). Прежний снимок — день, после supplier-синк фикса + баннера 18:00.
---
@@ -17,11 +17,11 @@
- Git-корень репозитория — папка `Документация/` (**не** `app/`).
- Remote: `CoralMinister/lidpotok` (приватный).
- Текущая локальная ветка: **`feat/project-migration-redesign`**.
- Локальный HEAD = origin/main HEAD = **`22e81cc`** (chore(gitleaks): allowlist Nuclei docs false-positive — tail коммит моего эпика «удаление проектов»; сверять `git log -1 origin/main`).
- Push паттерн: `git push origin <ветка>:main`. Мой push 21.05 (день): `3b6992d..22e81cc` (10 коммитов FF — спека+план+7 имплементаций+gitleaks-allowlist); pre-push gitleaks-full 1155/0, lychee 64/0.
- Pre-push lefthook прошёл чисто после добавления `.gitleaksignore` (Nuclei docs `-u http://...` ловился rule `curl-auth-user`false-positive из параллельной ветки `worktree-a8-infosec-tooling`, добавлен fingerprint).
- **Незакоммиченного нет** (фикс + тест запушены).
- Текущая локальная ветка: **`feat/test-deploy`** (дрейфнула — параллельная сессия переключила; сессия началась на `feat/project-migration-redesign`). Сверять `git branch --show-current`.
- **origin/main HEAD = `68f42ad`** (feat: баннер «до 18:00 МСК») ← `83613b4` (fix supplier: пересоздание донора + UI-бейдж). Сверять `git log -1 origin/main`.
- Локальная `feat/test-deploy` = **запушена на `origin/feat/test-deploy`** (HEAD `dcc1040`, push 21.05 вечер `bf4ed65..dcc1040`). Это **test-deploy эпик**: впереди main на 7 коммитов (test-deploy спека/runbook/флаг `SAAS_ADMIN_TEST_BYPASS` + дубли banner/supplier-фикс + мои 2 свежих: `b873c53` PlaywrightBridge timeout 75→180, `dcc1040` runbook supplier-каналы), отстаёт от main на 13 нормативка/observer-коммитов других сессий. **В main эпик НЕ влит** (несёт временный bypass-флаг + чужую незавершёнку; merge = отдельное решение, после удаления флага per runbook «После теста»). PlaywrightBridge-фикс при желании выносится в main отдельным cherry-pick (на main `TIMEOUT_SECONDS=75`).
- Push паттерн: `git push origin <ветка>:main`. Push 21.05 supplier+баннер: **через временный worktree от origin/main** (`git worktree add --detach C:\tmp-liderra-push origin/main` → cherry-pick `1220bdd 5fef464``push origin HEAD:main` = `cf0be8a..68f42ad`)чтобы не утащить чужой test-deploy и не перезатереть main. Worktree удалён. Так делать при дрейфе ветки + чужих коммитах в общей ветке.
- **Мои изменения запушены.** Локально на `feat/test-deploy` остаются дубли моих коммитов (другие SHA, чем на main) + чужой test-deploy — это нормально, дедуп при будущем rebase/merge ветки.
- Прочее незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты brain governance, не мои); untracked artifacts (см. §4).
- Остатки от rebase (безопасно): `stash@{0}` с не-моими hook/parallel-артефактами (+5 других parallel-стэшей); `/tmp/plan4-rebase-bak/` — устаревшие untracked-копии 2 observer-файлов (committed-версии origin/main авторитетнее).
@@ -83,11 +83,39 @@
- **Демо-доступ к порталу:** 5 изолированных компаний — `admin@demo.local` (Demo Tenant, 4 проекта) + `manager1@demo.local` (Компания Ивана) + `manager2@demo.local` (Компания Анны) + `manager3@demo.local` (Компания Петра) + `manager4@demo.local` (Компания Марии). Пароль у всех **`password`**. Каждый логин видит только своё. Админка `/admin/*` в local открыта любому залогиненному (`EnsureSaasAdmin` — стаб local/testing).
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`); портал — Vue 2 + Element UI;
`/admin/visit/rt` «Мои проекты» (форма add-project — Element UI внутри Vuetify v-dialog).
- **ТЕСТ-СЕРВЕР (Yandex Cloud, отдельно от dev!):** `http://111.88.246.137` (статический IP, HTTP, дверь
HTTP Basic Auth `liderra` / пароль в `/home/ubuntu/liderra-secrets.txt`). SSH `ssh -i ~/.ssh/liderra_deploy
ubuntu@111.88.246.137`; БД `sudo -u postgres psql -d liderra`. Демо-вход в портал: `admin@demo.local`/`password`
(tenant demo) + `info@lkomega.ru`/`password` (Компания 1, переименован из client1@liderra.test 21.05) +
`client2..4@liderra.test`/`password`. Runbook `docs/deploy/test-server-runbook.md`. **Все 3 канала поставщика
настроены и проверены вживую (21.05): supplier-портал указывает на тест-сервер → dev живых лидов больше не
получает.** Доустановлены Node20+Playwright+Chromium (`/var/www/.cache/ms-playwright`, владелец www-data).
- Оперативная карта проекта: `CLAUDE.md` (правится только плагином `claude-md-management`).
- Память Claude: индекс `MEMORY.md` — подгружается каждую сессию.
## 6. Текущие рабочие нити (детали — в памяти Claude)
- **Тест-сервер YC: все 3 канала миграции с поставщиком настроены вживую — DONE** (21.05.2026 вечер). webhook
(202/dup-200/404), CSV reconcile (185 реальных строк, ok), export (create+delete external_id=12764235). Чинились
5 пробелов: `.env` SUPPLIER creds (как dev), webhook secret 17→48-hex, allowlist `[]``["0.0.0.0/0"]` (TODO сузить),
**Node не стоял** → поставлен Node20+Playwright+Chromium, PlaywrightBridge timeout 75→180 (2GB VM). Supplier-портал
`/admin/user/api``http://111.88.246.137/...` HTTP/Активный (dev живых лидов больше не получает). Запушено
`origin/feat/test-deploy dcc1040` (`b873c53` timeout + `dcc1040` runbook). **TODO:** привязать client1..4 к реальным
supplier-каналам (pivot) иначе лиды ghost; HTTPS после домена; сузить allowlist; cherry-pick timeout в main.
Детали — память `project_supplier_channels_2026-05-18.md` (§21.05) + runbook §«Каналы миграции».
- **Supplier-синк: пересоздание удалённого донора + UI-бейдж + баннер 18:00 — DONE+ЗАПУШЕНО** (21.05.2026, main `83613b4`+`68f42ad`).
Заказчик пожаловался «одни проекты у разных ЛК» (оказалось — общий cookie браузера между вкладками, не баг) и «стек у поставщика
не создаётся». Диагностика (systematic-debugging): (1) донор 7913XXXXXXX на портале **БЫЛ всё время** (listProjects API вернул
449 проектов, 12742042-44 присутствуют — заказчик не нашёл визуально из-за сортировки списка портала); (2) реальная проблема —
бейдж «Sync pending» залипал: online-режим пишет связь в pivot `project_supplier_links`, а `aggregateSyncStatus` читал legacy FK
`supplier_b{1,2,3}_project_id` (в online NULL) → **фикс: online теперь заполняет и FK-колонки**; (3) ночной cron падал на
`archived_at` (worker держал старый код после дропа колонки) → **фикс: `php artisan queue:restart`**. Плюс safety-net: в обоих
джобах (`SyncSupplierProjectJob` + `SyncSupplierProjectsJob`) перед update сверяем external_id с живым `listProjects` и пересоздаём
мёртвых доноров in-place без удаления записей (на supplier_projects висят лиды/списания — лид №358/сделка №50 целы). Реальный
синк проекта 22 прогнан (донор подтверждён жив). Регрессия: Pest Supplier **107/107** + Plan5 **52/52**, Vitest ProjectsView
**11/11**, vue-tsc 0. **Баннер 18:00**: `v-alert` на странице «Проекты», закрывается крестиком (localStorage), информационный
(без блокировок); наш cutoff = 18:00 МСК (синк-крон), не 21:00/22:00 портала. NB tinker зависает на этой машине (кириллица в
пути) → для разовых скриптов use standalone bootstrap (`require bootstrap/app.php` + Kernel::bootstrap), не `artisan tinker file`.
- **Удаление проектов вместо архива + дедуп источника + человеческие ошибки — DONE+ЗАПУШЕНО** (21.05.2026 день,
`3b6992d..22e81cc`, 10 коммитов FF в main). По заказу: (1) кнопка «архивировать» заменена на настоящее удаление
(если по проекту есть сделки — блок с понятным сообщением, иначе hard delete + удаление донора у поставщика