feat реклама за показы: письмо клиенту и колокольчик на ответ Яндекса

This commit is contained in:
Дмитрий
2026-07-28 09:23:53 +03:00
parent 70b421e15b
commit 4ddfa7b0f3
4 changed files with 183 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: по его рекламной кампании пришёл ответ Яндекса.
* Получателей ставит вызывающий код через Mail::to($email)->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,
],
);
}
}
@@ -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,
]);
}
}
}
@@ -0,0 +1,9 @@
<p>По вашей рекламной кампании «{{ $campaignName }}» пришёл ответ Яндекса:</p>
<blockquote style="border-left: 3px solid #0F6E56; padding-left: 12px; margin: 16px 0;">
{{ $body }}
</blockquote>
<p>Открыть кампанию в личном кабинете и ответить: раздел «Реклама» кампания «{{ $campaignName }}».</p>
<p style="color: #666; font-size: 13px;">Это письмо отправлено автоматически, отвечать на него не нужно.</p>
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Mail\AdModerationMessageMail;
use App\Models\AdCampaign;
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\CampaignMessageService;
use Illuminate\Support\Facades\Mail;
function notifyCampaign(): array
{
$tenant = Tenant::factory()->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();
});