feat(смс-клиент): страница отказа по ссылке из СМС — без входа, номер маской, с лимитом
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер в стоп-листе именно той компании, от которой пришло сообщение. - таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится - сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух) - публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67 - ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров; защита проверена вырезанием, тест на 429 краснеет - неизвестная ссылка отвечает 404 без подсказок - служебное соединение только в этом контроллере (тенант-контекста у страницы нет) + GRANT для crm_supplier_worker на client_sms_optouts - CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон — после выката, страницу надо открыть телефоном. Ветка feat/client-sms-broadcast, песочница, в main не влито. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClientSmsOptout;
|
||||
use App\Models\ClientSmsUnsubscribeLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Страница отказа от рассылки по короткой ссылке из СМС (строки листа 1.6–1.8).
|
||||
*
|
||||
* Без авторизации: человек, получивший СМС, — не наш пользователь и войти не
|
||||
* может. Отсюда три правила, каждое обязательное:
|
||||
* · номер показывается только маской — страницу открывает кто угодно;
|
||||
* · ограничение частоты — иначе перебор ссылок превращается в утечку номеров;
|
||||
* · неизвестная ссылка отвечает 404 без подсказок, существует она или нет.
|
||||
*
|
||||
* Соединение служебное (`pgsql_supplier`): тенант-контекста здесь нет и быть не
|
||||
* может — мы узнаём клиента ИЗ ссылки, а не наоборот, поэтому изоляция по
|
||||
* тенанту сработала бы как «строк нет». Обход изоляции сознательно заперт в этом
|
||||
* одном классе, а сам tenant_id всё равно проставляется явно.
|
||||
*/
|
||||
final class PublicUnsubscribeController extends Controller
|
||||
{
|
||||
private const CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function show(string $token, Request $request): View
|
||||
{
|
||||
$link = $this->link($token);
|
||||
|
||||
return view('unsubscribe', [
|
||||
'token' => $token,
|
||||
'masked' => $this->mask($link->phone),
|
||||
'done' => $request->boolean('done'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(string $token): RedirectResponse
|
||||
{
|
||||
$link = $this->link($token);
|
||||
|
||||
ClientSmsOptout::on(self::CONNECTION)->firstOrCreate(
|
||||
['tenant_id' => $link->tenant_id, 'phone' => $link->phone],
|
||||
['source' => ClientSmsOptout::SOURCE_RECIPIENT],
|
||||
);
|
||||
|
||||
return redirect("/s/{$token}?done=1");
|
||||
}
|
||||
|
||||
private function link(string $token): ClientSmsUnsubscribeLink
|
||||
{
|
||||
$link = ClientSmsUnsubscribeLink::on(self::CONNECTION)->whereKey($token)->first();
|
||||
|
||||
if ($link === null) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/** 79990000067 → «+7 *** *** ** 67» */
|
||||
private function mask(string $phone): string
|
||||
{
|
||||
return '+7 *** *** ** '.substr($phone, -2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Короткая ссылка отказа из СМС: токен → пара «клиент + номер».
|
||||
*
|
||||
* Соединение обычное. Изоляция по тенанту живёт на таблице (RLS), и всё, что
|
||||
* создаёт ссылки, работает внутри тенант-контекста. Публичная страница
|
||||
* `/s/{token}` контекста не имеет — она одна читает через служебное соединение
|
||||
* явным `->on(...)`, чтобы обход изоляции был виден в одном месте, а не был
|
||||
* молча включён для всего модуля.
|
||||
*
|
||||
* Источник: db/schema.sql, table `client_sms_unsubscribe_links`.
|
||||
*/
|
||||
class ClientSmsUnsubscribeLink extends Model
|
||||
{
|
||||
protected $table = 'client_sms_unsubscribe_links';
|
||||
|
||||
protected $primaryKey = 'token';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'token',
|
||||
'tenant_id',
|
||||
'phone',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'created_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
// защита от накрутки счётчика.
|
||||
RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown'));
|
||||
|
||||
// sms-unsubscribe (строка листа 1.8): публичная страница отказа `/s/{token}`.
|
||||
// Ссылка короткая и без авторизации — без лимита перебор токенов становится
|
||||
// способом собрать чужие номера. 20/мин с IP: живому человеку хватает с запасом.
|
||||
RateLimiter::for('sms-unsubscribe', fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'));
|
||||
|
||||
RateLimiter::for('api-v1', function (Request $request) {
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
$bearer = str_starts_with($header, 'Bearer ')
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientSms;
|
||||
|
||||
use App\Models\ClientSmsUnsubscribeLink;
|
||||
|
||||
/**
|
||||
* Токен страницы отказа. Один и тот же на пару «клиент + номер» — иначе при
|
||||
* каждой рассылке одному человеку уезжала бы новая ссылка (строка листа 1.9).
|
||||
*
|
||||
* В алфавите нет 0/O/o и 1/l/I: ссылку диктуют вслух и переписывают руками,
|
||||
* а перепутанная буква — чужая страница отказа.
|
||||
*/
|
||||
final class ClientSmsUnsubscribeLinkService
|
||||
{
|
||||
private const ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
private const LENGTH = 12;
|
||||
|
||||
public function tokenFor(int $tenantId, string $phone): string
|
||||
{
|
||||
$existing = ClientSmsUnsubscribeLink::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('phone', $phone)
|
||||
->value('token');
|
||||
|
||||
if ($existing !== null) {
|
||||
return (string) $existing;
|
||||
}
|
||||
|
||||
do {
|
||||
$token = $this->randomToken();
|
||||
} while (ClientSmsUnsubscribeLink::whereKey($token)->exists());
|
||||
|
||||
ClientSmsUnsubscribeLink::create([
|
||||
'token' => $token,
|
||||
'tenant_id' => $tenantId,
|
||||
'phone' => $phone,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function randomToken(): string
|
||||
{
|
||||
$out = '';
|
||||
$last = strlen(self::ALPHABET) - 1;
|
||||
|
||||
for ($i = 0; $i < self::LENGTH; $i++) {
|
||||
$out .= self::ALPHABET[random_int(0, $last)];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Строка листа 1.9: короткая ссылка отказа из СМС. Одна и та же на пару
|
||||
* «клиент + номер» (unique), иначе одному человеку при каждой рассылке уезжала
|
||||
* бы новая ссылка, а старые копились бы вечно.
|
||||
*
|
||||
* RLS включён как у тенантского стоп-листа: в таблице лежит телефон живого
|
||||
* человека. Публичная страница `/s/{token}` читает строку БЕЗ тенант-контекста,
|
||||
* поэтому она (и только она) ходит служебной ролью — отсюда GRANT для
|
||||
* crm_supplier_worker. Без него страница получит отказ доступа.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_sms_unsubscribe_links', function (Blueprint $table) {
|
||||
$table->string('token', 12)->primary();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->string('phone', 11);
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
|
||||
$table->unique(['tenant_id', 'phone']);
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE client_sms_unsubscribe_links ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement('ALTER TABLE client_sms_unsubscribe_links FORCE ROW LEVEL SECURITY');
|
||||
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_sms_unsubscribe_links');
|
||||
DB::statement("CREATE POLICY tenant_isolation ON client_sms_unsubscribe_links USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
|
||||
|
||||
DB::statement('GRANT SELECT, INSERT ON client_sms_unsubscribe_links TO crm_app_user');
|
||||
DB::statement('GRANT SELECT ON client_sms_unsubscribe_links TO crm_supplier_worker');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_sms_unsubscribe_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Строка листа 1.6: публичная страница отказа `/s/{token}` пишет номер в стоп-лист
|
||||
* клиента, не имея тенант-контекста — она узнаёт клиента ИЗ ссылки. Поэтому ходит
|
||||
* служебной ролью (`pgsql_supplier` = crm_supplier_worker, политика srv_bypass).
|
||||
*
|
||||
* Прав на `client_sms_optouts` у этой роли не было: таблицу заводили под клиентский
|
||||
* кабинет, где роль всегда crm_app_user. Без этого GRANT'а страница отказа падала бы
|
||||
* отказом доступа — то есть человек, попросивший больше не писать, не был бы услышан.
|
||||
*
|
||||
* ⚠️ При выкате на управляемый кластер после накатки миграций ПЕРЕзапустить
|
||||
* `db/03_service_bypass_policies.sql`: политика srv_bypass выдаётся скриптом по
|
||||
* списку таблиц с RLS, и новая `client_sms_unsubscribe_links` без него останется
|
||||
* без cross-tenant доступа служебных ролей (тихий ноль, а не ошибка).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('GRANT SELECT, INSERT ON client_sms_optouts TO crm_supplier_worker');
|
||||
DB::statement('GRANT USAGE, SELECT ON SEQUENCE client_sms_optouts_id_seq TO crm_supplier_worker');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('REVOKE SELECT, INSERT ON client_sms_optouts FROM crm_supplier_worker');
|
||||
DB::statement('REVOKE USAGE, SELECT ON SEQUENCE client_sms_optouts_id_seq FROM crm_supplier_worker');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
{{-- Страница отказа от СМС-рассылки (строки листа 1.6, 1.7). Открывает человек
|
||||
с телефона, по ссылке из СМС: без входа, без Vue-сборки, одна кнопка.
|
||||
Номер — только маской: страницу может открыть кто угодно. --}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Отказ от СМС-рассылки</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px 20px;
|
||||
background: #F6F3EC;
|
||||
color: #012019;
|
||||
font: 16px/1.5 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 2px 16px rgba(1, 32, 25, .08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.phone {
|
||||
font-family: ui-monospace, "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 18px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
background: #0F6E56;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0c5844;
|
||||
}
|
||||
|
||||
.done {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 20px 0 0;
|
||||
font-size: 14px;
|
||||
color: #4a5c57;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@if ($done)
|
||||
<h1>Готово</h1>
|
||||
<p class="done">Больше не напишем на номер {{ $masked }}.</p>
|
||||
@else
|
||||
<h1>Отказ от СМС-рассылки</h1>
|
||||
<p class="phone">Номер {{ $masked }}</p>
|
||||
<form method="POST" action="/s/{{ $token }}">
|
||||
@csrf
|
||||
<button type="submit">Больше не писать</button>
|
||||
</form>
|
||||
<p class="note">Отказ действует только на рассылки той компании, от которой пришло сообщение.</p>
|
||||
@endif
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -665,6 +665,16 @@ Route::get('/kak-eto-rabotaet', function () {
|
||||
return response()->file($page, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
});
|
||||
|
||||
// Публичная страница отказа от СМС-рассылки (строки листа 1.6–1.8). БЕЗ авторизации:
|
||||
// получатель СМС — не наш пользователь. Ограничение частоты обязательно — перебор
|
||||
// ссылок иначе превращается в утечку номеров. Регистрируется до fallback'а.
|
||||
Route::middleware('throttle:sms-unsubscribe')->group(function () {
|
||||
Route::get('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@show')
|
||||
->where('token', '[A-Za-z0-9]{12}');
|
||||
Route::post('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@store')
|
||||
->where('token', '[A-Za-z0-9]{12}');
|
||||
});
|
||||
|
||||
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
|
||||
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
|
||||
// beforeEach (они регистрируются в момент теста, до запроса).
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsOptout;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Строки листа 1.6, 1.7, 1.8: страница отказа по короткой ссылке из СМС.
|
||||
*
|
||||
* Первый маршрут модуля БЕЗ авторизации: получатель СМС — не наш пользователь.
|
||||
* Отсюда три требования, каждое проверяется отдельно: номер только маской,
|
||||
* перебор токенов упирается в ограничение частоты, неизвестный токен молчит.
|
||||
*
|
||||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
// Страница ходит служебным соединением (тенант-контекста у неё нет) — в тестах
|
||||
// оба соединения обязаны видеть одну транзакцию, иначе засеянная ссылка «не найдена».
|
||||
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('sms-unsubscribe');
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->token = app(ClientSmsUnsubscribeLinkService::class)
|
||||
->tokenFor($this->tenant->id, '79990000067');
|
||||
});
|
||||
|
||||
it('человек по ссылке отказывается — и попадает в стоп-лист того клиента', function () {
|
||||
$this->post("/s/{$this->token}")->assertRedirect();
|
||||
|
||||
$row = ClientSmsOptout::where('tenant_id', $this->tenant->id)
|
||||
->where('phone', '79990000067')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->source)->toBe(ClientSmsOptout::SOURCE_RECIPIENT);
|
||||
});
|
||||
|
||||
it('повторный отказ по той же ссылке не ломается', function () {
|
||||
$this->post("/s/{$this->token}")->assertRedirect();
|
||||
$this->post("/s/{$this->token}")->assertRedirect();
|
||||
|
||||
expect(ClientSmsOptout::where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('страница не показывает номер целиком — только маской', function () {
|
||||
$this->get("/s/{$this->token}")
|
||||
->assertOk()
|
||||
->assertDontSee('79990000067')
|
||||
->assertSee('** 67', false);
|
||||
});
|
||||
|
||||
it('неизвестный токен не выдаёт, существует он или нет', function () {
|
||||
$this->get('/s/zzzzzzzzzzzz')->assertNotFound();
|
||||
});
|
||||
|
||||
it('перебор ссылок упирается в ограничение частоты', function () {
|
||||
$status = 200;
|
||||
|
||||
foreach (range(1, 30) as $i) {
|
||||
$status = $this->get('/s/aaaaaaaaaaaa')->status();
|
||||
|
||||
if ($status === 429) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($status)->toBe(429);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsUnsubscribeLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Строка листа 1.9: ссылка на один и тот же номер у одного клиента всегда одна
|
||||
* и та же. Иначе при каждой рассылке одному человеку уезжала бы новая ссылка,
|
||||
* а старые копились бы вечно.
|
||||
*
|
||||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
$this->service = app(ClientSmsUnsubscribeLinkService::class);
|
||||
});
|
||||
|
||||
it('токен на пару «клиент + номер» постоянный', function () {
|
||||
$first = $this->service->tokenFor($this->tenant->id, '79990000001');
|
||||
$second = $this->service->tokenFor($this->tenant->id, '79990000001');
|
||||
|
||||
expect($second)->toBe($first)
|
||||
->and(strlen($first))->toBe(12)
|
||||
->and(ClientSmsUnsubscribeLink::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('у разных клиентов на один номер разные ссылки', function () {
|
||||
$mine = $this->service->tokenFor($this->tenant->id, '79990000001');
|
||||
$theirs = $this->service->tokenFor($this->otherTenant->id, '79990000001');
|
||||
|
||||
expect($theirs)->not->toBe($mine)
|
||||
->and(ClientSmsUnsubscribeLink::count())->toBe(2);
|
||||
});
|
||||
|
||||
it('по токену находится тот самый клиент и тот самый номер', function () {
|
||||
$token = $this->service->tokenFor($this->tenant->id, '79990000002');
|
||||
|
||||
$link = ClientSmsUnsubscribeLink::findOrFail($token);
|
||||
|
||||
expect($link->tenant_id)->toBe($this->tenant->id)
|
||||
->and($link->phone)->toBe('79990000002');
|
||||
});
|
||||
|
||||
it('в токене нет букв и цифр, которые путаются при чтении вслух', function () {
|
||||
$tokens = [];
|
||||
foreach (range(1, 20) as $i) {
|
||||
$tokens[] = $this->service->tokenFor($this->tenant->id, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
// 0/O/o, 1/l/I — человек диктует ссылку по телефону, путаница = чужая страница
|
||||
expect(implode('', $tokens))->not->toMatch('/[0OolI1]/');
|
||||
});
|
||||
@@ -8,6 +8,44 @@
|
||||
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
|
||||
> поэтому при сведении они перенумерованы. Содержание не менялось.
|
||||
|
||||
## v9.02 (2026-07-30) — Клиентская СМС, Этап 1: короткие ссылки отказа `client_sms_unsubscribe_links` + право служебной роли на стоп-лист
|
||||
|
||||
Две миграции:
|
||||
|
||||
**1. `app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php`**
|
||||
— новая таблица `client_sms_unsubscribe_links`:
|
||||
|
||||
- `token` varchar(12) **PRIMARY KEY** — то, что видно в СМС после `liderra.ru/s/`;
|
||||
- `tenant_id` bigint FK `tenants` ON DELETE CASCADE;
|
||||
- `phone` varchar(11);
|
||||
- `created_at` timestamptz DEFAULT now();
|
||||
- `UNIQUE (tenant_id, phone)` — одна ссылка на пару «клиент + номер» навсегда.
|
||||
|
||||
RLS **включён** (`ENABLE` + `FORCE`) с политикой `tenant_isolation` — в таблице
|
||||
лежит телефон живого человека. GRANT'ы: `crm_app_user` — `SELECT, INSERT`
|
||||
(создание ссылок идёт из тенант-контекста), `crm_supplier_worker` — `SELECT`.
|
||||
|
||||
**2. `app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php`**
|
||||
— `crm_supplier_worker` получает `SELECT, INSERT` на `client_sms_optouts`
|
||||
и `USAGE, SELECT` на её последовательность.
|
||||
|
||||
**Зачем служебная роль.** Публичная страница `/s/{token}` открывается без входа
|
||||
в систему: получатель СМС — не наш пользователь. Клиента она узнаёт ИЗ ссылки,
|
||||
то есть тенант-контекста в момент запроса нет и быть не может — под обычной
|
||||
ролью политика изоляции вернула бы «строк нет», а запись отказа получила бы
|
||||
отказ доступа. Обход изоляции заперт в одном классе
|
||||
(`PublicUnsubscribeController`, явный `->on('pgsql_supplier')`), а не включён
|
||||
для всего модуля; `tenant_id` при этом проставляется явно.
|
||||
|
||||
⚠️ **При выкате на управляемый кластер** после накатки миграций ПЕРЕзапустить
|
||||
`db/03_service_bypass_policies.sql` — политика `srv_bypass` выдаётся скриптом по
|
||||
списку таблиц с RLS, и новая `client_sms_unsubscribe_links` без этого останется
|
||||
без cross-tenant доступа служебных ролей. Симптом — тихий ноль (страница отказа
|
||||
скажет «ссылка не найдена» на живую ссылку), а не ошибка.
|
||||
|
||||
Ветка `feat/client-sms-broadcast`, в main не влита. Номер записи при сведении
|
||||
может быть перенумерован.
|
||||
|
||||
## v9.01 (2026-07-30) — Клиентская СМС, Этап 1: у отказа появился источник и комментарий (`client_sms_optouts`)
|
||||
|
||||
Миграция `app/database/migrations/2026_07_30_100100_add_source_to_client_sms_optouts.php`
|
||||
|
||||
Reference in New Issue
Block a user