18135b47b4
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
«На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
{ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.
## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
(.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
+ freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).
## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.
TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.
Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3.8 KiB
PHP
84 lines
3.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ClientTg\Campaign;
|
|
use App\Models\InAppNotification;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\NotificationService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
/**
|
|
* Задача 4.3 — уведомление об ОДОБРЕНИИ/запуске Telegram-кампании (зеркало отказа
|
|
* 5.2). Опросчик вердикта (задача 3.4) при `approved` зовёт
|
|
* notifyTelegramCampaignApproved: in-app всем активным юзерам тенанта, БЕЗ
|
|
* pref-гейта (важное операционное сообщение доходит всегда, как отказ).
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
function tgApproveCampaign(int $tenantId): Campaign
|
|
{
|
|
return Campaign::query()->create([
|
|
'tenant_id' => $tenantId,
|
|
'status' => Campaign::STATUS_LAUNCHED,
|
|
'ad_text' => 'Приходите к нам в канал',
|
|
'ad_link' => 'https://t.me/example_channel',
|
|
'ord_category' => 'Размещение рекламы',
|
|
'budget_cap_rub' => '1000.00',
|
|
'audience_kind' => Campaign::AUDIENCE_LIST,
|
|
'planned_count' => 2,
|
|
'estimated_cost_rub' => '0.00',
|
|
'created_by' => 1,
|
|
]);
|
|
}
|
|
|
|
it('одобрение шлёт in-app всем активным юзерам тенанта с текстом «одобрена/запущена»', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$u1 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
|
$u2 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
|
$campaign = tgApproveCampaign($tenant->id);
|
|
|
|
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
|
|
|
|
expect(InAppNotification::where('user_id', $u1->id)->exists())->toBeTrue()
|
|
->and(InAppNotification::where('user_id', $u2->id)->exists())->toBeTrue();
|
|
|
|
$notif = InAppNotification::where('user_id', $u1->id)->first();
|
|
expect($notif->title)->toContain('одобрена')
|
|
->and($notif->body)->toContain('показы пошли');
|
|
});
|
|
|
|
it('уведомление об одобрении доходит даже без включённых настроек уведомлений', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
// Дефолтные настройки фабрики НЕ содержат события tg_campaign_approved —
|
|
// проверяем, что уведомление доходит без pref-гейта.
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
|
$campaign = tgApproveCampaign($tenant->id);
|
|
|
|
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
|
|
|
|
expect(InAppNotification::where('user_id', $user->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
it('неактивный пользователь уведомление об одобрении НЕ получает', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
|
$campaign = tgApproveCampaign($tenant->id);
|
|
|
|
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
|
|
|
|
expect(InAppNotification::count())->toBe(0);
|
|
});
|
|
|
|
it('изоляция тенанта: чужой активный юзер уведомление НЕ получает', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$tenantB = Tenant::factory()->create();
|
|
$foreign = User::factory()->create(['tenant_id' => $tenantB->id, 'is_active' => true]);
|
|
$campaign = tgApproveCampaign($tenant->id);
|
|
|
|
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
|
|
|
|
expect(InAppNotification::where('user_id', $foreign->id)->exists())->toBeFalse();
|
|
});
|