Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.
4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
(audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).
4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.
4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).
4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).
Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.
Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ:
✅ Админ-API + кнопка в админке для удаления ПДн субъекта
✅ Сервис анонимизации (users + supplier_leads + deals + webhook_log)
✅ Журнал факта удаления в pd_processing_log
❌ БЕЗ формы самообслуживания на стороне субъекта
❌ БЕЗ email-подтверждения
❌ БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме)
Что добавлено:
* Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме)
* Сервис `App\Services\Pd\PdErasureService::eraseSubject()`:
- cross-tenant через pgsql_supplier (BYPASSRLS)
- транзакционно (rollback при ошибке)
- users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null,
phone→+7000{id}
- supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true}
- deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone)
- webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at}
- pd_processing_log запись action=deleted за каждого user/lead с
actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает)
- При requestId — pd_subject_requests SET status=completed, completed_at,
response_text счёт
* Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure
* Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests,
GET /{id}, POST /{id}/erase
* Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания +
кнопка Анонимизировать для request_type=deletion); ESLint требует
v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой
* Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests
NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads
имеет только phone (нет contact_*); deals имеет phone+contact_name (нет
contact_email); webhook_log JSONB. PdErasureService адаптирован под факт.
Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store +
deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log,
pd_processing_log запись, request status→completed, отклонение
не-deletion типов, gate saas-admin, InvalidArgumentException.
Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4).
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.
Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
точность при передаче через JSON.
Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.
Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
₽-полями.
Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.
PHPStan baseline регенерирован. cspell-glossary +консистентности.
Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).
Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>