feat(Membership): add notification for user and admin during registration
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m30s

This commit is contained in:
2026-07-15 13:58:48 +02:00
parent a886bdd947
commit b4845511a6
15 changed files with 371 additions and 18 deletions

View File

@@ -4,8 +4,12 @@ namespace App\Filament\Resources\Memberships\Pages;
use App\Filament\Resources\Memberships\MembershipResource;
use App\Models\Membership;
use App\Notifications\MembershipValidatedNotification;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\DatePicker;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
class EditMembership extends EditRecord
@@ -15,14 +19,37 @@ class EditMembership extends EditRecord
protected function getHeaderActions(): array
{
return [
Action::make('validate')
->label(__('memberships.actions.validate'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->visible(fn (Membership $record) => $record->status === 'pending')
->form([
DatePicker::make('start_date')
->label(Membership::getAttributeLabel('start_date'))
->required(),
DatePicker::make('end_date')
->label(Membership::getAttributeLabel('end_date'))
->required(),
])
->fillForm(fn (Membership $record): array => [
'start_date' => $record->start_date,
'end_date' => $record->end_date,
])
->action(function (Membership $record, array $data): void {
$record->update([
'status' => 'active',
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
]);
$record->member->notify(new MembershipValidatedNotification($record->fresh(['member', 'package'])));
}),
DeleteAction::make(),
];
}
/**
* @property Membership $record
* @return string|Htmlable
*
*/
public function getTitle(): string|Htmlable
{

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberNewRequestMemberNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
public readonly Package $package,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$template = NotificationTemplate::findByIdentifier('member_new_request_member');
$vars = [
'member_name' => $this->member->full_name,
'package_name' => $this->package->name,
'app_name' => 'Le Retzien Libre',
];
return (new MailMessage)
->subject($template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $template->renderBody($vars),
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Notifications;
use App\Models\Membership;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MembershipValidatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly Membership $membership) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$template = NotificationTemplate::findByIdentifier('membership_validated');
$vars = [
'member_name' => $this->membership->member->full_name,
'package_name' => $this->membership->package->name,
'start_date' => $this->membership->start_date?->format('d/m/Y') ?? '',
'end_date' => $this->membership->end_date?->format('d/m/Y') ?? '',
'app_name' => config('app.name'),
];
return (new MailMessage)
->subject($template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $template->renderBody($vars),
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Package;
use App\Notifications\MemberDeactivatedAdminNotification;
use App\Notifications\MemberDeactivatedMemberNotification;
use App\Notifications\MemberNewRequestAdminNotification;
use App\Notifications\MemberNewRequestMemberNotification;
use Illuminate\Support\Facades\Notification;
class MemberService
@@ -55,6 +56,8 @@ class MemberService
Notification::route('mail', config('app.admin_email'))
->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount']));
$member->notify(new MemberNewRequestMemberNotification($member, $package));
event(new MemberRegistered($member));
return $member;

View File

@@ -4,7 +4,6 @@ namespace Database\Factories;
use App\Models\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class MemberFactory extends Factory
{
@@ -13,18 +12,16 @@ class MemberFactory extends Factory
public function definition(): array
{
return [
'keycloak_id' => $this->faker->word(),
'email' => $this->faker->unique()->safeEmail(),
'firstname' => $this->faker->firstName(),
'lastname' => $this->faker->lastName(),
'phone' => $this->faker->phoneNumber(),
'address' => $this->faker->address(),
'phone1' => $this->faker->phoneNumber(),
'address' => $this->faker->streetAddress(),
'city' => $this->faker->city(),
'zipcode' => $this->faker->word(),
'last_login_at' => Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'deleted_at' => Carbon::now(),
'zipcode' => $this->faker->postcode(),
'country' => 'FR',
'status' => 'pending',
'nature' => 'physical',
];
}
}

View File

@@ -9,6 +9,45 @@ class NotificationTemplateSeeder extends Seeder
{
public function run(): void
{
NotificationTemplate::updateOrCreate(
['identifier' => 'member_new_request_member'],
[
'name' => 'Nouvelle demande d\'adhésion — membre',
'subject' => 'Votre demande d\'adhésion a bien été reçue — {app_name}',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Nous avons bien reçu votre demande d\'adhésion pour la formule <strong>{package_name}</strong>.</p>'
.'<p>Votre dossier est en attente de validation par notre équipe. Vous recevrez un e-mail dès que votre adhésion aura été traitée.</p>'
.'<p>Merci pour votre confiance et bienvenue dans l\'association !</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'package_name' => 'Nom de la formule choisie',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'membership_validated'],
[
'name' => 'Adhésion validée — membre',
'subject' => 'Votre adhésion a été validée — {app_name}',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Votre adhésion pour la formule <strong>{package_name}</strong> a été validée par notre équipe.</p>'
.'<p><strong>Début :</strong> {start_date}<br><strong>Fin :</strong> {end_date}</p>'
.'<p>Vous pouvez dès à présent accéder à vos services. Pour toute question, n\'hésitez pas à nous contacter.</p>'
.'<p>Merci pour votre adhésion !</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'package_name' => 'Nom de la formule',
'start_date' => 'Date de début de l\'adhésion',
'end_date' => 'Date de fin de l\'adhésion',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'subscription_expired_phase1'],
[

View File

@@ -49,5 +49,7 @@ return [
'actions' => [
'view_profile' => 'View member profile',
'validate' => 'Validate membership',
'validate_missing_dates' => 'Please fill in the start and end dates before validating.',
],
];

View File

@@ -49,5 +49,7 @@ return [
'actions' => [
'view_profile' => 'Voir le profil du membre',
'validate' => 'Valider l\'adhésion',
'validate_missing_dates' => 'Veuillez renseigner les dates de début et de fin avant de valider.',
],
];

View File

@@ -1,5 +1,4 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\Services\Pages\EditService::__invoke
* @see app/Filament/Resources/Services/Pages/EditService.php:7

View File

@@ -1,5 +1,4 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\Users\Pages\CreateUser::__invoke
* @see app/Filament/Resources/Users/Pages/CreateUser.php:7

View File

@@ -1,5 +1,4 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\Users\Pages\ListUsers::__invoke
* @see app/Filament/Resources/Users/Pages/ListUsers.php:7

View File

@@ -1,5 +1,4 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../wayfinder'
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../wayfinder'
/**
* @see \App\Http\Controllers\Auth\AuthenticatedSessionController::create
* @see app/Http/Controllers/Auth/AuthenticatedSessionController.php:20

View File

@@ -1,5 +1,4 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
/**
* @see \BezhanSalleh\FilamentShield\Resources\Roles\Pages\ViewRole::__invoke
* @see vendor/bezhansalleh/filament-shield/src/Resources/Roles/Pages/ViewRole.php:7

View File

@@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
use App\Notifications\MemberNewRequestAdminNotification;
use App\Notifications\MemberNewRequestMemberNotification;
use App\Services\MemberService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class MemberNewRequestMemberNotificationTest extends TestCase
{
use RefreshDatabase;
private function createPackage(): Package
{
return Package::create([
'identifier' => 'one-year',
'name' => 'Adhésion annuelle',
'price' => 12.00,
'is_active' => true,
]);
}
private function memberData(Package $package): array
{
return [
'firstname' => 'Jean',
'lastname' => 'Dupont',
'email' => 'jean.dupont@example.com',
'phone1' => '0600000000',
'address' => '1 rue de la Paix',
'zipcode' => '44000',
'city' => 'Nantes',
'package' => $package->identifier,
'amount' => 12.00,
];
}
public function test_member_receives_confirmation_notification_on_registration(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
$member = Member::where('email', 'jean.dupont@example.com')->firstOrFail();
Notification::assertSentTo($member, MemberNewRequestMemberNotification::class);
}
public function test_confirmation_notification_contains_correct_member_and_package(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
$member = Member::where('email', 'jean.dupont@example.com')->firstOrFail();
Notification::assertSentTo(
$member,
MemberNewRequestMemberNotification::class,
function (MemberNewRequestMemberNotification $notification) use ($member, $package): bool {
return $notification->member->id === $member->id
&& $notification->package->id === $package->id;
}
);
}
public function test_admin_notification_is_also_sent_on_registration(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
Notification::assertSentOnDemand(MemberNewRequestAdminNotification::class);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Models\Member;
use App\Models\Membership;
use App\Models\NotificationTemplate;
use App\Models\Package;
use App\Notifications\MembershipValidatedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class MembershipValidatedNotificationTest extends TestCase
{
use RefreshDatabase;
private function createPendingMembership(): Membership
{
$package = Package::create([
'identifier' => 'one-year',
'name' => 'Adhésion annuelle',
'price' => 12.00,
'is_active' => true,
]);
$member = Member::factory()->create([
'status' => 'pending',
'nature' => 'physical',
]);
return Membership::create([
'member_id' => $member->id,
'package_id' => $package->id,
'status' => 'pending',
'amount' => 12.00,
'payment_status' => 'unpaid',
]);
}
public function test_member_receives_notification_when_membership_is_validated(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
$membership->update(['status' => 'active']);
$membership->member->notify(new MembershipValidatedNotification($membership->fresh(['member', 'package'])));
Notification::assertSentTo($membership->member, MembershipValidatedNotification::class);
}
public function test_validated_notification_contains_correct_membership(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
$membership->update(['status' => 'active']);
$freshMembership = $membership->fresh(['member', 'package']);
$freshMembership->member->notify(new MembershipValidatedNotification($freshMembership));
Notification::assertSentTo(
$membership->member,
MembershipValidatedNotification::class,
function (MembershipValidatedNotification $notification) use ($membership): bool {
return $notification->membership->id === $membership->id;
}
);
}
public function test_notification_is_not_sent_when_status_is_not_active(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
Notification::assertNotSentTo($membership->member, MembershipValidatedNotification::class);
}
}