From b4845511a6974c5ca3febcf1ab00ab7f175081ae Mon Sep 17 00:00:00 2001 From: Nebulae Date: Wed, 15 Jul 2026 13:58:48 +0200 Subject: [PATCH] feat(Membership): add notification for user and admin during registration --- .../Memberships/Pages/EditMembership.php | 35 ++++++- .../MemberNewRequestMemberNotification.php | 54 +++++++++++ .../MembershipValidatedNotification.php | 52 ++++++++++ app/Services/MemberService.php | 3 + database/factories/MemberFactory.php | 15 ++- .../seeders/NotificationTemplateSeeder.php | 39 ++++++++ lang/en/memberships.php | 2 + lang/fr/memberships.php | 2 + .../Resources/Services/Pages/EditService.ts | 1 - .../Resources/Users/Pages/CreateUser.ts | 1 - .../Resources/Users/Pages/ListUsers.ts | 1 - .../Auth/AuthenticatedSessionController.ts | 1 - .../Resources/Roles/Pages/ViewRole.ts | 1 - ...MemberNewRequestMemberNotificationTest.php | 96 +++++++++++++++++++ .../MembershipValidatedNotificationTest.php | 86 +++++++++++++++++ 15 files changed, 371 insertions(+), 18 deletions(-) create mode 100644 app/Notifications/MemberNewRequestMemberNotification.php create mode 100644 app/Notifications/MembershipValidatedNotification.php create mode 100644 tests/Feature/MemberNewRequestMemberNotificationTest.php create mode 100644 tests/Feature/MembershipValidatedNotificationTest.php diff --git a/app/Filament/Resources/Memberships/Pages/EditMembership.php b/app/Filament/Resources/Memberships/Pages/EditMembership.php index ebd386f..a9bacec 100644 --- a/app/Filament/Resources/Memberships/Pages/EditMembership.php +++ b/app/Filament/Resources/Memberships/Pages/EditMembership.php @@ -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,17 +19,40 @@ 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 + public function getTitle(): string|Htmlable { - return Membership::getAttributeLabel('membership') . ' #' . $this->record->id; + return Membership::getAttributeLabel('membership').' #'.$this->record->id; } } diff --git a/app/Notifications/MemberNewRequestMemberNotification.php b/app/Notifications/MemberNewRequestMemberNotification.php new file mode 100644 index 0000000..f93d758 --- /dev/null +++ b/app/Notifications/MemberNewRequestMemberNotification.php @@ -0,0 +1,54 @@ + + */ + 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 + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Notifications/MembershipValidatedNotification.php b/app/Notifications/MembershipValidatedNotification.php new file mode 100644 index 0000000..a083f1d --- /dev/null +++ b/app/Notifications/MembershipValidatedNotification.php @@ -0,0 +1,52 @@ + + */ + 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 + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index 3b6215e..03b3f22 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -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; diff --git a/database/factories/MemberFactory.php b/database/factories/MemberFactory.php index e705116..c6b764a 100644 --- a/database/factories/MemberFactory.php +++ b/database/factories/MemberFactory.php @@ -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', ]; } } diff --git a/database/seeders/NotificationTemplateSeeder.php b/database/seeders/NotificationTemplateSeeder.php index 61c8d26..da30ed4 100644 --- a/database/seeders/NotificationTemplateSeeder.php +++ b/database/seeders/NotificationTemplateSeeder.php @@ -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' => '

Bonjour {member_name},

' + .'

Nous avons bien reçu votre demande d\'adhésion pour la formule {package_name}.

' + .'

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.

' + .'

Merci pour votre confiance et bienvenue dans l\'association !

', + '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' => '

Bonjour {member_name},

' + .'

Votre adhésion pour la formule {package_name} a été validée par notre équipe.

' + .'

Début : {start_date}
Fin : {end_date}

' + .'

Vous pouvez dès à présent accéder à vos services. Pour toute question, n\'hésitez pas à nous contacter.

' + .'

Merci pour votre adhésion !

', + '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'], [ diff --git a/lang/en/memberships.php b/lang/en/memberships.php index 6fadc5e..17ea723 100644 --- a/lang/en/memberships.php +++ b/lang/en/memberships.php @@ -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.', ], ]; diff --git a/lang/fr/memberships.php b/lang/fr/memberships.php index c08981f..7639e61 100644 --- a/lang/fr/memberships.php +++ b/lang/fr/memberships.php @@ -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.', ], ]; diff --git a/resources/js/actions/App/Filament/Resources/Services/Pages/EditService.ts b/resources/js/actions/App/Filament/Resources/Services/Pages/EditService.ts index 436a9c6..ecf68c3 100644 --- a/resources/js/actions/App/Filament/Resources/Services/Pages/EditService.ts +++ b/resources/js/actions/App/Filament/Resources/Services/Pages/EditService.ts @@ -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 diff --git a/resources/js/actions/App/Filament/Resources/Users/Pages/CreateUser.ts b/resources/js/actions/App/Filament/Resources/Users/Pages/CreateUser.ts index 9736393..42ee8db 100644 --- a/resources/js/actions/App/Filament/Resources/Users/Pages/CreateUser.ts +++ b/resources/js/actions/App/Filament/Resources/Users/Pages/CreateUser.ts @@ -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 diff --git a/resources/js/actions/App/Filament/Resources/Users/Pages/ListUsers.ts b/resources/js/actions/App/Filament/Resources/Users/Pages/ListUsers.ts index daea0d5..c48c0b6 100644 --- a/resources/js/actions/App/Filament/Resources/Users/Pages/ListUsers.ts +++ b/resources/js/actions/App/Filament/Resources/Users/Pages/ListUsers.ts @@ -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 diff --git a/resources/js/actions/App/Http/Controllers/Auth/AuthenticatedSessionController.ts b/resources/js/actions/App/Http/Controllers/Auth/AuthenticatedSessionController.ts index 7911353..0ca0503 100644 --- a/resources/js/actions/App/Http/Controllers/Auth/AuthenticatedSessionController.ts +++ b/resources/js/actions/App/Http/Controllers/Auth/AuthenticatedSessionController.ts @@ -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 diff --git a/resources/js/actions/BezhanSalleh/FilamentShield/Resources/Roles/Pages/ViewRole.ts b/resources/js/actions/BezhanSalleh/FilamentShield/Resources/Roles/Pages/ViewRole.ts index 6c909fc..999c392 100644 --- a/resources/js/actions/BezhanSalleh/FilamentShield/Resources/Roles/Pages/ViewRole.ts +++ b/resources/js/actions/BezhanSalleh/FilamentShield/Resources/Roles/Pages/ViewRole.ts @@ -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 diff --git a/tests/Feature/MemberNewRequestMemberNotificationTest.php b/tests/Feature/MemberNewRequestMemberNotificationTest.php new file mode 100644 index 0000000..fb480c1 --- /dev/null +++ b/tests/Feature/MemberNewRequestMemberNotificationTest.php @@ -0,0 +1,96 @@ + '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); + } +} diff --git a/tests/Feature/MembershipValidatedNotificationTest.php b/tests/Feature/MembershipValidatedNotificationTest.php new file mode 100644 index 0000000..14e15a0 --- /dev/null +++ b/tests/Feature/MembershipValidatedNotificationTest.php @@ -0,0 +1,86 @@ + '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); + } +}