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