Compare commits

..

15 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
35 changed files with 2372 additions and 173 deletions
@@ -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
{
@@ -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;
@@ -425,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,
]);
}
}
}
}
}
/**
+24
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;
@@ -317,6 +318,29 @@ class SyncSupplierProjectJob implements ShouldQueue
]);
}
// 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.
@@ -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;
}
}
+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));
}
}
+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));
}
+15
View File
@@ -100,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
@@ -135,6 +146,10 @@ function dismissCutoffBanner(): void {
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);
});
@@ -7,7 +7,9 @@ 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')).
@@ -151,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);
});
+58
View File
@@ -272,3 +272,61 @@ describe('ProjectsView 18:00 cutoff banner', () => {
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();
});
+9
View File
@@ -1608,3 +1608,12 @@ lemed
дочерпывание
creds
незавершёнку
субдомен
субдомены
субдомена
брейнсторминг
брейнсторминга
гэп
артизан
Артизан
Sps
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-21T08:42:35.722Z
Last updated: 2026-05-22T09:23:38.723Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,14 +8,14 @@ Last updated: 2026-05-21T08:42:35.722Z
| 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 | ✅ | 71 episode(s) this month · Stop-hook + post-commit OK |
| 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: 71 episodes this month, 0 observer_error markers, 52 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).
## Алерт-индикаторы
@@ -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,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`».