diff --git a/app/app/Http/Controllers/PublicUnsubscribeController.php b/app/app/Http/Controllers/PublicUnsubscribeController.php
new file mode 100644
index 00000000..6ae0728c
--- /dev/null
+++ b/app/app/Http/Controllers/PublicUnsubscribeController.php
@@ -0,0 +1,71 @@
+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);
+ }
+}
diff --git a/app/app/Models/ClientSmsUnsubscribeLink.php b/app/app/Models/ClientSmsUnsubscribeLink.php
new file mode 100644
index 00000000..b8ca7d03
--- /dev/null
+++ b/app/app/Models/ClientSmsUnsubscribeLink.php
@@ -0,0 +1,45 @@
+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',
+ ];
+ }
+}
diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php
index fb173f2a..61d58000 100644
--- a/app/app/Providers/AppServiceProvider.php
+++ b/app/app/Providers/AppServiceProvider.php
@@ -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 ')
diff --git a/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php
new file mode 100644
index 00000000..ee8f81db
--- /dev/null
+++ b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php
@@ -0,0 +1,57 @@
+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;
+ }
+}
diff --git a/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php b/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php
new file mode 100644
index 00000000..62c04a62
--- /dev/null
+++ b/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php
@@ -0,0 +1,44 @@
+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');
+ }
+};
diff --git a/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php b/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php
new file mode 100644
index 00000000..cd67245f
--- /dev/null
+++ b/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ Отказ от СМС-рассылки
+
+
+
+
+ @if ($done)
+ Готово
+ Больше не напишем на номер {{ $masked }}.
+ @else
+ Отказ от СМС-рассылки
+ Номер {{ $masked }}
+
+ Отказ действует только на рассылки той компании, от которой пришло сообщение.
+ @endif
+
+
+
diff --git a/app/routes/web.php b/app/routes/web.php
index efd781ca..989fe1bb 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -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 (они регистрируются в момент теста, до запроса).
diff --git a/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php b/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php
new file mode 100644
index 00000000..44578b12
--- /dev/null
+++ b/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php
@@ -0,0 +1,73 @@
+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);
+});
diff --git a/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php b/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php
new file mode 100644
index 00000000..c30719a1
--- /dev/null
+++ b/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php
@@ -0,0 +1,59 @@
+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]/');
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 9ac8a615..3faab273 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -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`