refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup

Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 17:23:49 +03:00
parent 4652741c79
commit d783b73bd3
14 changed files with 131 additions and 128 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ class MonthlyPartitionManager
'auth_log' => 'created_at',
'activity_log' => 'created_at',
'tenant_operations_log' => 'created_at',
'webhook_log' => 'received_at',
// webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
'balance_transactions' => 'created_at',
'pd_processing_log' => 'created_at',
'saas_admin_audit_log' => 'created_at',
+6 -44
View File
@@ -18,7 +18,7 @@ use InvalidArgumentException;
* users: email, first_name, last_name, phone
* supplier_leads: phone, raw_payload (JSONB) нет contact_email/contact_phone
* deals: phone, contact_name нет отдельного contact_email
* webhook_log: raw_payload (JSONB)
* (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
*/
class PdErasureService
{
@@ -32,7 +32,7 @@ class PdErasureService
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
* @param int $actorAdminId ID saas_admin_users
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
* @return array{users: int, leads: int, deals: int, webhook_log: int}
* @return array{users: int, leads: int, deals: int}
*
* @throws InvalidArgumentException если оба email и phone null
*/
@@ -47,7 +47,7 @@ class PdErasureService
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
}
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
DB::connection(self::DB)->transaction(function () use (
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
@@ -176,50 +176,12 @@ class PdErasureService
$counts['deals'] = $deals->count();
// ------------------------------------------------------------------
// 4. webhook_log (raw_payload JSONB text-search)
// ------------------------------------------------------------------
$wlQuery = DB::connection(self::DB)->table('webhook_log');
$conditions = [];
$bindings = [];
if ($email !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$email.'%';
}
if ($phone !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$phone.'%';
}
if (! empty($conditions)) {
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
}
if ($tenantId !== null) {
$wlQuery->where('tenant_id', $tenantId);
}
// Batched update: обрабатываем по 500 строк
$wlCount = 0;
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
$ids = $rows->pluck('id')->all();
DB::connection(self::DB)->table('webhook_log')
->whereIn('id', $ids)
->update([
'raw_payload' => DB::connection(self::DB)->raw(
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
),
]);
$wlCount += count($ids);
});
$counts['webhook_log'] = $wlCount;
// ------------------------------------------------------------------
// 5. Обновить pd_subject_requests если requestId передан
// 4. Обновить pd_subject_requests если requestId передан
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
// ------------------------------------------------------------------
if ($requestId !== null) {
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}";
."deals={$counts['deals']}";
DB::connection(self::DB)->table('pd_subject_requests')
->where('id', $requestId)
-1
View File
@@ -22,7 +22,6 @@ class TenantFactory extends Factory
'subdomain' => 'tenant-'.Str::lower(Str::random(8)),
'organization_name' => fake()->company(),
'contact_email' => fake()->unique()->safeEmail(),
'webhook_token' => Str::random(64),
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => true,
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Удаление legacy-артефактов прямого webhook-канала.
*
* Spec: docs/superpowers/specs/2026-05-24-legacy-direct-webhook-removal-design.md
* Plan: docs/superpowers/plans/2026-05-24-legacy-direct-webhook-removal.md
*
* Что удаляем (финальный список по результатам Phase 1 impact-checks):
* - webhook_log (partitioned, 13 партиций) пустая на проде, источник = только удалённый ProcessWebhookJob
* - rejected_deals_log writer только ProcessWebhookJob, нет readers
* - tenants.webhook_token + tenants.webhook_token_rotated_at нет в UI/API, тесты почищены ниже
* - system_settings.low_balance_threshold_leads (seed) только legacy
*
* Phase 1 RED FLAG: webhook_dedup_keys ОСТАЁТСЯ (HistoricalImportService CSV-канал).
*
* pgsql_supplier connection BYPASSRLS-роль crm_supplier_worker (паттерн Спека B):
* под обычной crm_app_user DROP/ALTER без app.current_tenant_id GUC не пройдёт.
*/
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
// Partitioned table — DROP TABLE каскадит все 13 партиций.
$conn->statement('DROP TABLE IF EXISTS webhook_log CASCADE');
// NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал).
// RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет.
$conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE');
// tenants.webhook_token + webhook_token_rotated_at — нет в UI/API.
$conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at');
// Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob).
$conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'");
}
/**
* Откат пустая заглушка. Прод-restore из pg_dump backup.
* Этот метод существует только чтобы migrate:rollback не падал.
*/
public function down(): void
{
// НЕ восстанавливаем структуру — пустая заглушка.
// Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md).
}
};
-1
View File
@@ -77,7 +77,6 @@ foreach ($accounts as $a) {
[
'organization_name' => $a['org_name'],
'contact_email' => $user->email,
'webhook_token' => Str::random(64),
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => true,
@@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi
'organization_name' => 'PD User Test',
'contact_email' => 'pd-u@test.local',
'status' => 'active',
'webhook_token' => bin2hex(random_bytes(16)),
'balance_rub' => '0.00',
'balance_leads' => 0,
'is_trial' => false,
@@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
'organization_name' => 'Billing Test Co',
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'status' => 'active',
'balance_rub' => '5000.00',
'is_trial' => false,
@@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
'organization_name' => 'Affected Org',
'contact_email' => 'a@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'created_at' => now(),
]);
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
@@ -69,7 +69,6 @@ function ensureTenant(int $seed): int
'organization_name' => "Test Chain {$seed}",
'subdomain' => "test-chain-{$seed}",
'contact_email' => "chain{$seed}@example.com",
'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}",
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
@@ -17,7 +17,7 @@ beforeEach(function () {
$this->partitionsBefore = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->pluck('relname')->all();
});
@@ -25,7 +25,7 @@ afterEach(function () {
$partitionsAfter = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->pluck('relname')->all();
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
@@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па
$afterFirst = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->count();
// Повторный запуск — должен только skip'ать.
@@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па
$afterSecond = collect(DB::select("
SELECT relname FROM pg_class
WHERE relkind = 'r'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->count();
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
$output = Artisan::output();
expect($output)->toContain('0 created, 54 skipped');
expect($output)->toContain('0 created, 48 skipped');
});
test('--ahead=0 создаёт только текущий месяц', function () {
@@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно
'subdomain' => 'partition-test-'.uniqid(),
'organization_name' => 'PartitionTest',
'contact_email' => 'pt@test.local',
'webhook_token' => str_repeat('p', 64),
'api_key_limit' => 5,
]);
$projectId = DB::table('projects')->insertGetId([
@@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () {
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
expect(fn () => DB::statement(
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', -1)"
))->toThrow(QueryException::class);
});
@@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
// источник истины метрик.
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
// v8.35 (legacy webhook removal): 2 таблицы (webhook_log partitioned + rejected_deals_log)
// −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
$schema = file_get_contents($schemaPath);
expect($schema)->not->toBeFalse();
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
//
// 67 base tables = все CREATE TABLE минус PARTITION OF.
// 66 base tables = все CREATE TABLE минус PARTITION OF.
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(67);
expect($baseTables)->toBe(66);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
expect($createIndexes)->toBe(120); // v8.35: 5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1)
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
expect($createPolicies)->toBe(40); // v8.35: 2 политики (webhook_log + rejected_deals_log)
});
-2
View File
@@ -45,14 +45,12 @@ SQL);
'subdomain' => 'rls-tenant-a-'.uniqid(),
'organization_name' => 'RLS Tenant A',
'contact_email' => 'a@rls-test.local',
'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
$this->tenant2Id = DB::table('tenants')->insertGetId([
'subdomain' => 'rls-tenant-b-'.uniqid(),
'organization_name' => 'RLS Tenant B',
'contact_email' => 'b@rls-test.local',
'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
+32 -1
View File
@@ -2,7 +2,38 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.34, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля).
## v8.35 (2026-05-24) — legacy direct webhook removal
Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала
упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью.
**Удалено:**
- **Таблица `webhook_log`** (partitioned RANGE по `received_at`) + все дочерние партиции (DROP CASCADE).
Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён.
- **Таблица `rejected_deals_log`** (регулярная) — журнал отвергнутых лидов прямого webhook-канала.
- **Колонки `tenants.webhook_token` + `tenants.webhook_token_rotated_at`** — токен аутентификации
прямого webhook. Индекс `idx_tenants_webhook_token` удалён вместе с колонкой.
- **Seed-строка `low_balance_threshold_leads`** в `system_settings` — использовалась только
удалённым `LowBalanceNotification` mailable'ом.
- **Seed-строки `webhook_log_retention_days` + `webhook_log_retention_months`** в `system_settings`.
**Оставлено (НЕ удалено):**
- **`webhook_dedup_keys`** — используется CSV-каналом (`HistoricalImportService`) для идемпотентности.
- **`failed_webhook_jobs.webhook_log_id`** — orphan BIGINT (без FK с v8.31/W1); оставлен.
- **`outbound_webhook_subscriptions` + `outbound_webhook_deliveries`** — исходящий webhook (тенант → внешний URL); не затронут.
**Метрики:** −2 таблицы / −5 индексов / −2 RLS-политики.
66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies.
**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts`
**Связанные изменения кода:**
- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log`
- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log`
## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index
+18 -56
View File
@@ -1,12 +1,13 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
-- Версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
-- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров
-- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
@@ -630,8 +631,7 @@ CREATE TABLE tenants (
contact_email VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
webhook_token VARCHAR(64) UNIQUE NOT NULL,
webhook_token_rotated_at TIMESTAMPTZ,
-- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal)
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
locale VARCHAR(10) DEFAULT 'ru',
-- Биллинг
@@ -678,7 +678,7 @@ CREATE TABLE tenants (
);
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
@@ -1415,7 +1415,7 @@ CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions
-- -----------------------------------------------------------------------------
-- outbound_webhook_deliveries — журнал попыток доставки (v8.4)
-- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток
-- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток
-- от 30 секунд до 24 часов).
-- -----------------------------------------------------------------------------
CREATE TABLE outbound_webhook_deliveries (
@@ -1919,32 +1919,9 @@ COMMENT ON TABLE in_app_notifications IS
'read_at при клике. RLS: tenant isolation.';
-- -----------------------------------------------------------------------------
-- webhook_log — лог принятых webhook (раздел 5.7)
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at).
-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK
-- партиционированной таблицы с единичным FK-столбцом).
CREATE TABLE webhook_log (
id BIGSERIAL,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook)
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
processed_at TIMESTAMPTZ,
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
error TEXT,
-- v8.29: supplier webhook audit columns
source VARCHAR(50), -- 'supplier' | 'tenant'
status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
lead_id BIGINT, -- supplier_leads.id при статусе 'received'
ip_address INET, -- клиентский IP
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, received_at) -- v8.31: composite PK
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
-- webhook_log удалена в v8.35 (legacy direct webhook removal).
-- Канал входящих webhook (прямой приём от тенантов) упразднён.
-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService).
-- -----------------------------------------------------------------------------
@@ -1969,21 +1946,8 @@ CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02)
-- -----------------------------------------------------------------------------
-- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7)
-- РЕТЕНШН: бессрочно (опционально 12 месяцев)
-- -----------------------------------------------------------------------------
CREATE TABLE rejected_deals_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03)
-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal).
-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом.
-- -----------------------------------------------------------------------------
@@ -2803,7 +2767,7 @@ VALUES
INSERT INTO system_settings (key, value, type, description) VALUES
('schema_version', '8.3', 'string', 'Текущая версия схемы БД'),
('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'),
-- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом)
('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'),
('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'),
('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
@@ -2811,7 +2775,7 @@ INSERT INTO system_settings (key, value, type, description) VALUES
('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'),
('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'),
('password_min_length', '10', 'int', 'Минимальная длина пароля'),
('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'),
-- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена)
-- VAPID (Web Push, раздел 17.4)
('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'),
('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
@@ -2839,11 +2803,11 @@ INSERT INTO system_settings (key, value, type, description) VALUES
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
-- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
-- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
-- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена).
('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'),
('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'),
('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)');
@@ -2916,9 +2880,8 @@ ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline)
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
-- webhook_log / rejected_deals_log: таблицы удалены в v8.35
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже)
@@ -2958,9 +2921,8 @@ CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = cur
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены)
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);