Powiadomienia (w aplikacji i mailowe)
View on GitHubDodaj dedykowany mail transakcyjny
Przewodnik 2 pokrywa ponowne użycie generycznego szablonu NotificationMail (jeden współdzielony układ tematu/treści/przycisku akcji dla każdego NotificationType). Czasem to niewłaściwe narzędzie: ProjectInvitationMail już udowadnia ten wzorzec dla innego przypadku — zaproszenie do projektu zawsze musi zostać wysłane mailem (nie ma odpowiednika w aplikacji, a odbiorca może nawet nie mieć jeszcze konta, żeby trzymać preferencję), i potrzebuje własnego tematu i layoutu, nie generycznego. Ten przewodnik dodaje drugi dedykowany mail tego kształtu: powiadamianie nowego właściciela mailem — zawsze, niezależnie od jego ustawień powiadomień — gdy własność projektu zostaje na niego przeniesiona.
Kiedy sięgnąć po to zamiast po NotificationService::notify()
Użyj dedykowanej klasy Notification (ten przewodnik), gdy mail:
- musi zostać wysłany bezwarunkowo, ignorując preferencje
NotificationSettingodbiorcy (przeniesienie własności jest wystarczająco poważne, żeby "wyłączyłem maile" nie miało go wyciszać, tak samo jak mail z zaproszeniem nie może respektować preferencji, które jeszcze nie istnieją dla kogoś, kto nie jest użytkownikiem). - potrzebuje własnego tematu i układu treści, nie generycznego kształtu tytuł/wiadomość/przycisk, jaki renderuje
NotificationMail/emails/notification.blade.php.
W przeciwnym razie preferuj NotificationService::notify() z przewodnika 2 — to mniej kodu i jedyna ścieżka, która dodatkowo tworzy powiadomienie w aplikacji i respektuje preferencje per użytkownik.
Krok 1 — Stwórz klasę Notification
Nowy plik: app/Notifications/OwnershipTransferredMail.php
<?php
namespace App\Notifications;
use App\Models\Project;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Queue\Middleware\RateLimited;
class OwnershipTransferredMail extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(
public readonly Project $project,
public readonly User $previousOwner,
) {}
public function backoff(): array
{
return [10, 30, 60, 120];
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function middleware(object $notifiable, string $channel): array
{
return match ($channel) {
'mail' => [new RateLimited('emails')],
default => [],
};
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("You're now the owner of \"{$this->project->name}\"")
->view('emails.ownership-transferred', [
'project' => $this->project,
'previousOwner' => $this->previousOwner,
]);
}
}
Każde pole tutaj odzwierciedla dokładnie ProjectInvitationMail: ShouldQueue + Queueable, $tries/backoff() dla ponawiania przy przejściowych błędach SMTP, współdzielony RateLimiter 'emails' (zarejestrowany raz w AppServiceProvider::boot() — RateLimiter::for('emails', fn () => Limit::perSecond(2)) — każda wysyłająca mail Notification w tym repozytorium ponownie używa tego samego nazwanego limitera, nie rejestruj nowego), oraz własny subject()/view() w toMail() zamiast delegowania do współdzielonego widoku emails.notification.
Krok 2 — Stwórz widok
Nowy plik: resources/views/emails/ownership-transferred.blade.php
@extends('emails.layout')
@section('subject', "You're now the owner of \"{$project->name}\"")
@section('content')
<h1 class="email-text" style="margin:0 0 16px; font-size:20px; line-height:28px; color:#f7f7f8;">You're the new owner of "{{ $project->name }}"</h1>
<p class="email-text" style="margin:0 0 24px; color:#f7f7f8;">{{ $previousOwner->name }} transferred ownership of "{{ $project->name }}" to you. You now have full control over the project's settings, members, and roles.</p>
@include('emails.partials.button', ['url' => route('projects.show', $project->id), 'text' => 'View project'])
@endsection
Każdy mail transakcyjny robi @extends('emails.layout') — to właśnie stąd bierze się baner, kontener świadomy trybu ciemnego/jasnego i stopka "dostrój swoje powiadomienia" za darmo (zobacz emails/layout.blade.php); nigdy nie buduj pełnego dokumentu HTML ręcznie. Do każdego linku call-to-action użyj ponownie emails.partials.button zamiast surowego <a>, żeby stylowanie przycisku było identyczne w każdym mailu.
Krok 3 — Wystrzel go z właściwego miejsca
Plik: app/Services/ProjectMemberService.php, transferOwnership():
public function transferOwnership(Project $project, User $currentOwner, User $newOwner): void
{
if ($this->projectMemberRepository->roleOf($project, $currentOwner->id) !== RoleType::OWNER) {
throw ValidationException::withMessages([
'owner' => 'Only the current owner can transfer ownership.',
]);
}
$this->assertIsMember($project, $newOwner);
if ($newOwner->id === $currentOwner->id) {
throw ValidationException::withMessages([
'user' => 'This user already owns the project.',
]);
}
$this->projectMemberRepository->updateRole($project, $currentOwner->id, RoleType::ADMIN);
$this->projectMemberRepository->updateRole($project, $newOwner->id, RoleType::OWNER);
$this->roleService->syncSystemRoleForMember($project, $currentOwner->id, RoleType::ADMIN);
$this->roleService->syncSystemRoleForMember($project, $newOwner->id, RoleType::OWNER);
$this->activityLogService->log(
$project->id,
"Transferred project ownership from $currentOwner->name to $newOwner->name"
);
$newOwner->notify(new OwnershipTransferredMail($project, $currentOwner));
}
Dodaj use App\Notifications\OwnershipTransferredMail; do importów tego pliku. $newOwner->notify(...) — standardowy trait Illuminate\Notifications\Notifiable na User — to sposób wysyłania do prawdziwego, istniejącego użytkownika; routing na żądanie (Notification::route('mail', $email)->notify(...), jak używa ProjectInvitationService::sendInvitationNotification()) jest tylko dla odbiorców, którzy mogą jeszcze nie mieć wiersza User.
Krok 4 — Testy
- Nowy plik
tests/Feature/Notifications/OwnershipTransferredMailTest.php— odzwierciedla dokładnie kształttests/Feature/Notifications/NotificationMailTest.php:<?php use App\Models\Project; use App\Models\User; use App\Notifications\OwnershipTransferredMail; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Queue\Middleware\RateLimited; uses(RefreshDatabase::class); test('it sends only via mail', function () { $project = Project::factory()->make(); $previousOwner = User::factory()->make(); $notification = new OwnershipTransferredMail($project, $previousOwner); expect($notification->via(new User))->toBe(['mail']); }); test('it builds a mail message with the subject and renders the project and previous owner', function () { $project = Project::factory()->make(['name' => 'Orbit']); $previousOwner = User::factory()->make(['name' => 'Ada Lovelace']); $newOwner = User::factory()->make(); $notification = new OwnershipTransferredMail($project, $previousOwner); $mail = $notification->toMail($newOwner); expect($mail->subject)->toBe('You\'re now the owner of "Orbit"') ->and($mail->view)->toBe('emails.ownership-transferred') ->and($mail->viewData['project'])->toBe($project) ->and($mail->viewData['previousOwner'])->toBe($previousOwner); $rendered = $mail->render(); expect($rendered)->toContain('Ada Lovelace transferred ownership of "Orbit" to you') ->and($rendered)->toContain('View project'); }); test('it rate-limits the mail channel', function () { $project = Project::factory()->make(); $previousOwner = User::factory()->make(); $notification = new OwnershipTransferredMail($project, $previousOwner); $middleware = $notification->middleware(new User, 'mail'); expect($middleware)->toHaveCount(1) ->and($middleware[0])->toBeInstanceOf(RateLimited::class); }); tests/Feature/ProjectMemberServiceTest.php— dodajNotification::fake()(importIlluminate\Support\Facades\Notification) do testu'it can transfer ownership to another member and demotes the previous owner to admin', a następnie asercuj:Notification::assertSentTo($member, OwnershipTransferredMail::class);tests/Feature/ProjectMemberControllerTest.php— bez zmian; już ćwiczy tę samą ścieżkę kodutransferOwnership()pośrednio przez warstwę HTTP i nie asercuje na powiadomieniach.
