diff --git a/app/app/Mail/AdModerationMessageMail.php b/app/app/Mail/AdModerationMessageMail.php new file mode 100644 index 00000000..53f14a13 --- /dev/null +++ b/app/app/Mail/AdModerationMessageMail.php @@ -0,0 +1,44 @@ +queue(new ...). + */ +final class AdModerationMessageMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public readonly string $campaignName, + public readonly int $campaignId, + public readonly string $body, + ) {} + + public function envelope(): Envelope + { + return new Envelope(subject: 'Ответ Яндекса по рекламной кампании «'.$this->campaignName.'»'); + } + + public function content(): Content + { + return new Content( + view: 'mail.ad-moderation-message', + with: [ + 'campaignName' => $this->campaignName, + 'campaignId' => $this->campaignId, + 'body' => $this->body, + ], + ); + } +} diff --git a/app/app/Services/Advertising/CampaignMessageService.php b/app/app/Services/Advertising/CampaignMessageService.php index 201bc1cc..32bb66e7 100644 --- a/app/app/Services/Advertising/CampaignMessageService.php +++ b/app/app/Services/Advertising/CampaignMessageService.php @@ -4,8 +4,14 @@ declare(strict_types=1); namespace App\Services\Advertising; +use App\Mail\AdModerationMessageMail; use App\Models\AdCampaign; use App\Models\AdCampaignMessage; +use App\Models\User; +use App\Services\NotificationService; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Throwable; /** * Единственное место, где создаются сообщения ленты кампании. Здесь же защита от дублей @@ -14,6 +20,8 @@ use App\Models\AdCampaignMessage; */ class CampaignMessageService { + public function __construct(private readonly NotificationService $notifications) {} + /** * Слова Яндекса. Дубль по паре «кампания + баннер» не пишем: опрос модерации бежит * по расписанию и до перемены решения присылает одну и ту же причину при каждом @@ -55,6 +63,64 @@ class CampaignMessageService $message->setConnection($connection); $message->save(); + $this->notify($campaign, $body); + return $message; } + + /** Служебная отметка портала: расписка, а не новость — клиента ею не дёргаем. */ + public function postSystem(AdCampaign $campaign, string $body): ?AdCampaignMessage + { + $body = trim($body); + if ($body === '') { + return null; + } + + $message = new AdCampaignMessage([ + 'tenant_id' => (int) $campaign->tenant_id, + 'campaign_id' => (int) $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_SYSTEM, + 'body' => $body, + ]); + $message->setConnection($campaign->getConnectionName()); + $message->save(); + + return $message; + } + + /** + * Письмо и колокольчик всем живым пользователям тенанта. Внутри всё под Throwable: + * упавшая почта не должна стирать саму новость — сообщение уже в ленте, клиент + * увидит его, когда зайдёт. + */ + private function notify(AdCampaign $campaign, string $body): void + { + try { + $users = User::query() + ->where('tenant_id', $campaign->tenant_id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($users as $user) { + $this->notifications->notifyInApp( + $user, + 'ad_moderation', + 'Ответ Яндекса по рекламе', + mb_substr($body, 0, 500), + ['campaign_id' => (int) $campaign->id], + ); + + if (is_string($user->email) && $user->email !== '') { + Mail::to($user->email)->queue(new AdModerationMessageMail( + (string) $campaign->name, (int) $campaign->id, $body, + )); + } + } + } catch (Throwable $e) { + Log::warning('Не смогли уведомить клиента об ответе Яндекса: '.$e->getMessage(), [ + 'campaign' => $campaign->id, + ]); + } + } } diff --git a/app/resources/views/mail/ad-moderation-message.blade.php b/app/resources/views/mail/ad-moderation-message.blade.php new file mode 100644 index 00000000..6bba4808 --- /dev/null +++ b/app/resources/views/mail/ad-moderation-message.blade.php @@ -0,0 +1,9 @@ +

По вашей рекламной кампании «{{ $campaignName }}» пришёл ответ Яндекса:

+ +
+ {{ $body }} +
+ +

Открыть кампанию в личном кабинете и ответить: раздел «Реклама» → кампания «{{ $campaignName }}».

+ +

Это письмо отправлено автоматически, отвечать на него не нужно.

diff --git a/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php b/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php new file mode 100644 index 00000000..c4aad7bd --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php @@ -0,0 +1,64 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + return [$tenant, $user, $campaign]; +} + +it('на слова Яндекса клиенту уходит письмо и загорается колокольчик', function () { + Mail::fake(); + + [$tenant, $user, $campaign] = notifyCampaign(); + + app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не подошло'); + + Mail::assertQueued(AdModerationMessageMail::class, fn ($mail) => $mail->hasTo($user->email)); + + expect(InAppNotification::where('tenant_id', $tenant->id)->where('user_id', $user->id)->count())->toBe(1); +}); + +/** + * Служебная отметка «документ отправлен в Яндекс» — расписка, а не новость. + * Дёргать ею клиента незачем. + */ +it('на служебную отметку портала письмо не уходит', function () { + Mail::fake(); + + [$tenant, , $campaign] = notifyCampaign(); + + app(CampaignMessageService::class)->postSystem($campaign, 'Документ отправлен в Яндекс'); + + Mail::assertNothingQueued(); + expect(InAppNotification::where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +/** + * Почта — вещь ненадёжная. Если письмо не ушло, сообщение всё равно обязано остаться + * в ленте: клиент увидит его, когда зайдёт. Иначе один сбой почты стирает саму новость. + */ +it('упавшая почта не мешает сообщению лечь в ленту', function () { + [, , $campaign] = notifyCampaign(); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('почта легла')); + + $message = app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не подошло'); + + expect($message)->not->toBeNull() + ->and($message->exists)->toBeTrue(); +});