Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -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).');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,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
|
||||
|
||||
@@ -60,11 +60,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 +117,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 +160,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 +176,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 +263,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 +309,22 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
// 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 +341,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 +378,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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -101,6 +126,15 @@ 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 singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -8,10 +8,13 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
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().
|
||||
|
||||
@@ -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,36 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,12 @@ lemed
|
||||
батч
|
||||
ретраит
|
||||
шеринге
|
||||
|
||||
# Supplier dead-donor fix + баннер 18:00 (2026-05-21)
|
||||
дрейфнувшей
|
||||
дропа
|
||||
коммитах
|
||||
доустановлены
|
||||
дочерпывание
|
||||
creds
|
||||
незавершёнку
|
||||
|
||||
@@ -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-21T08:42:35.722Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,12 +8,12 @@ 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 | ✅ | 71 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: 71 episodes this month, 0 observer_error markers, 52 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 5
|
||||
- Last /brain-retro: 2 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,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/`.
|
||||
- Имя поддомена и сам домен — от заказчика.
|
||||
@@ -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