From 81dbe4275d1d86b95912f63c80779d18b1b46ada Mon Sep 17 00:00:00 2001
From: Nebulae
Date: Sat, 18 Jul 2026 15:19:48 +0200
Subject: [PATCH] feat(Notification process: text, app name, membership
validation + project update)
---
.env.example | 1 +
PROJECT_STRUCTURE.md | 46 +++++++++++++------
README.md | 42 ++++++++++-------
.../Memberships/Schemas/MembershipForm.php | 3 +-
app/Http/Requests/Forms/MembershipRequest.php | 15 +++++-
.../MemberDeactivatedMemberNotification.php | 2 +-
.../MemberNewRequestAdminNotification.php | 4 +-
.../MemberNewRequestMemberNotification.php | 2 +-
.../MembershipValidatedNotification.php | 7 +--
.../SubscriptionExpiredPhase1.php | 1 +
app/Providers/AppServiceProvider.php | 11 +++++
app/Services/MemberService.php | 7 +--
config/app.php | 1 +
.../seeders/NotificationTemplateSeeder.php | 4 +-
lang/en/memberships.php | 1 +
lang/fr/memberships.php | 1 +
resources/js/pages/forms/membership.tsx | 42 +++++++++++++++--
.../notifications/mail-template.blade.php | 4 +-
18 files changed, 142 insertions(+), 52 deletions(-)
diff --git a/.env.example b/.env.example
index b5b14d3..4f53a21 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
APP_NAME="Roxane - Le Retzien Libre"
+FRONT_NAME="Le Retzien Libre"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md
index 5e1e535..28bc874 100644
--- a/PROJECT_STRUCTURE.md
+++ b/PROJECT_STRUCTURE.md
@@ -165,9 +165,24 @@ resources/js/components/
## Notifications
-| Classe | Canal | Description |
-| ------------------------- | ----- | --------------------------------------------- |
-| SubscriptionExpiredPhase1 | Email | Notification d'expiration d'adhesion (queued) |
+Toutes les notifications sont queueable (`ShouldQueue`). Le template Blade `notifications/mail-template.blade.php` est partagé et accepte une variable `$appName` pour différencier les emails membres des emails admin.
+
+| Classe | Canal | Destinataire | Déclencheur |
+| ----------------------------------- | ----- | ------------ | ------------------------------------------------ |
+| ContactNewRequestNotification | Email | Admin | Soumission formulaire de contact |
+| MemberNewRequestAdminNotification | Email | Admin | Soumission formulaire d'adhésion |
+| MemberNewRequestMemberNotification | Email | Membre | Soumission formulaire d'adhésion (confirmation) |
+| MembershipValidatedNotification | Email | Membre | Validation admin d'une adhésion (action Filament)|
+| MemberDeactivatedMemberNotification | Email | Membre | Désactivation d'un membre |
+| MemberDeactivatedAdminNotification | Email | Admin | Désactivation d'un membre |
+| AdminInvitationNotification | Email | Admin | Création d'un compte administrateur |
+| AdminPasswordResetNotification | Email | Admin | Réinitialisation de mot de passe admin |
+| ServiceActivationRequestNotification| Email | Admin | Demande d'activation de service (dashboard) |
+| SubscriptionExpiredPhase1 | Email | Membre | Job planifié d'expiration d'adhésion |
+
+### Config app_name dans les emails
+- Emails membres : `config('app.front_name')` (variable `FRONT_NAME` dans `.env`)
+- Emails admin : `config('app.name')` (variable `APP_NAME` dans `.env`)
---
@@ -179,15 +194,16 @@ Langues : **fr**, **en** — fichiers dans `lang/{locale}/` : contacts, members,
## TODOs identifies dans le code
-| Fichier | TODO |
-|---------------------------|----------------------------------------------------------------------------|
-| ContactService | Envoyer un email a l'administrateur |
-| MemberService | Envoyer des emails au membre + admin a la desactivation |
-| SubscriptionExpiredPhase1 | Creer un template generique + UI backend pour le contenu |
-| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
-| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
-| SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
-| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id |
-| Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
-| Traduction | Crawler le prrojet pour retrouver toutes les clés manquantes |
-| Global | PHPstan niveau 8 |
+| Fichier | TODO |
+|--------------------------------|--------------------------------------------------------------------------------|
+| Notifications (x9) | Propager `appName` dans `->view()` pour toutes les notifications restantes |
+| SubscriptionExpiredPhase1 | UI backend pour éditer le contenu du template de notification |
+| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
+| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
+| SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
+| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id |
+| MembershipValidatedNotification| Ajouter lien HelloAsso vers le paiement quand disponible |
+| Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
+| Traduction | Crawler le projet pour retrouver toutes les clés manquantes |
+| Global | PHPstan niveau 8 |
+| dev-routes.php | Supprimer la route de test mail (/test/mail) avant mise en production |
diff --git a/README.md b/README.md
index ef148ec..e7a38e0 100644
--- a/README.md
+++ b/README.md
@@ -12,17 +12,24 @@ The project is developed in the context of **Le Retzien Libre**, a non-profit as
**Back office (administrators)**
- Member management (status, nature, groups)
-- Subscription and package management
+- Subscription and package management with validation workflow (pending → active)
- Manual and automated synchronization with third-party services
- Role and permission management (Spatie Permissions + Filament Shield)
- Two-factor authentication for admin accounts
**Front office (members)**
-- Registration and membership form
+- Registration and membership form with real-time field validation
- Personal dashboard with access to associated services
- Profile and password management
- Two-factor authentication
+**Email notifications**
+- Admin notified on new contact request and new membership request (with direct link to the membership record)
+- Member receives a confirmation email on registration and a validation email when approved
+- Member notified on account deactivation and subscription expiry
+- Queued notifications (`ShouldQueue`) — requires a running queue worker
+- Separate application name for member emails (`FRONT_NAME`) vs admin emails (`APP_NAME`)
+
**Integrations**
- Dolibarr ERP (member and subscription import via REST API)
- ISPConfig (mail and web hosting account management via SOAP)
@@ -65,7 +72,7 @@ The project is developed in the context of **Le Retzien Libre**, a non-profit as
### 1. Clone the repository
```bash
-git clone https://github.com/your-org/roxane.git
+git clone https://github.com/SabrinaNebulae/roxane.git
cd roxane
```
@@ -87,6 +94,8 @@ Edit `.env` and configure:
- Database connection (`DB_*`)
- Redis connection (`REDIS_*`)
- Mail configuration (`MAIL_*`)
+- Application names: `APP_NAME` (admin panel), `FRONT_NAME` (member-facing emails)
+- Admin email: `ADMIN_EMAIL` (recipient for all admin notifications)
- Third-party service credentials (Dolibarr, ISPConfig, Nextcloud)
### 4. Database setup
@@ -186,19 +195,20 @@ php artisan test --compact
## Known TODOs
-| Area | Description |
-|---------------------------|---------------------------------------------------------------------|
-| ContactService | Send email notification to administrator on new contact request |
-| MemberService | Send emails to member and admin on deactivation |
-| SubscriptionExpiredPhase1 | Generic template + backend UI for notification content management |
-| User.php | Restrict admin access in production to @retzien.fr emails |
-| SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper |
-| SyncISPConfigMailMembers | Handle multiple email addresses per member |
-| SyncISPConfigMailMembers | Track `ispconfig_client_id` |
-| Global | Make Roxane fully generic for any association ERP use case |
-| Translations | Audit project for missing translation keys |
-| Global | Raise PHPStan to level 8 |
-| V2 | Keycloak SSO integration (OIDC) for front office authentication |
+| Area | Description |
+|-----------------------------|----------------------------------------------------------------------------------|
+| Notifications | Propagate `appName` view variable to all remaining notifications |
+| SubscriptionExpiredPhase1 | Backend UI for editing notification template content |
+| MembershipValidatedNotif. | Add HelloAsso payment link once integration is available |
+| User.php | Restrict admin access in production to @retzien.fr emails |
+| SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper |
+| SyncISPConfigMailMembers | Handle multiple email addresses per member |
+| SyncISPConfigMailMembers | Track `ispconfig_client_id` |
+| Global | Make Roxane fully generic for any association ERP use case |
+| Translations | Audit project for missing translation keys |
+| Global | Raise PHPStan to level 8 |
+| dev-routes.php | Remove `/test/mail` debug route before production deployment |
+| V2 | Keycloak SSO integration (OIDC) for front office authentication |
---
diff --git a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php
index cf9aa96..0c22cf6 100644
--- a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php
+++ b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php
@@ -57,7 +57,8 @@ class MembershipForm
TextEntry::make('member.full_name')
->label(Membership::getAttributeLabel('member_id')),
TextEntry::make('author.name')
- ->label(Membership::getAttributeLabel('admin_id')),
+ ->label(Membership::getAttributeLabel('admin_id'))
+ ->default(__('memberships.sections.author_not_set')),
TextEntry::make('created_at')
->label(Membership::getAttributeLabel('created_at')),
])
diff --git a/app/Http/Requests/Forms/MembershipRequest.php b/app/Http/Requests/Forms/MembershipRequest.php
index 2329833..f328d3f 100644
--- a/app/Http/Requests/Forms/MembershipRequest.php
+++ b/app/Http/Requests/Forms/MembershipRequest.php
@@ -12,6 +12,17 @@ class MembershipRequest extends FormRequest
return true;
}
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'phone1.digits' => 'Le numéro de téléphone doit contenir exactement 10 chiffres.',
+ 'zipcode.digits' => 'Le code postal doit contenir exactement 5 chiffres.',
+ ];
+ }
+
/**
* @return array|string>
*/
@@ -23,9 +34,9 @@ class MembershipRequest extends FormRequest
'email' => 'required|email|max:255',
'company' => 'nullable|string|max:255',
'address' => 'required|string|max:255',
- 'zipcode' => 'required|string|max:255',
+ 'zipcode' => ['required', 'digits:5'],
'city' => 'required|string|max:255',
- 'phone1' => 'required|string|max:255',
+ 'phone1' => ['required', 'digits:10'],
'package' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'cgu' => 'required|accepted',
diff --git a/app/Notifications/MemberDeactivatedMemberNotification.php b/app/Notifications/MemberDeactivatedMemberNotification.php
index d9ded71..6b9183c 100644
--- a/app/Notifications/MemberDeactivatedMemberNotification.php
+++ b/app/Notifications/MemberDeactivatedMemberNotification.php
@@ -29,7 +29,7 @@ class MemberDeactivatedMemberNotification extends Notification implements Should
$vars = [
'member_name' => $this->member->full_name,
- 'app_name' => config('app.name'),
+ 'app_name' => config('app.front_name'),
];
return (new MailMessage)
diff --git a/app/Notifications/MemberNewRequestAdminNotification.php b/app/Notifications/MemberNewRequestAdminNotification.php
index 8b9a87c..72d238e 100644
--- a/app/Notifications/MemberNewRequestAdminNotification.php
+++ b/app/Notifications/MemberNewRequestAdminNotification.php
@@ -2,7 +2,6 @@
namespace App\Notifications;
-use App\Filament\Resources\Members\MemberResource;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
@@ -19,6 +18,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
public readonly Member $member,
public readonly Package $package,
public readonly float $amount,
+ public readonly string $membershipUrl,
) {}
/**
@@ -44,7 +44,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
])),
'package_name' => $this->package->name,
'amount' => number_format($this->amount, 2, ',', ' '),
- 'member_url' => MemberResource::getUrl('edit', ['record' => $this->member->id]),
+ 'membership_url' => $this->membershipUrl,
'app_name' => config('app.name'),
];
diff --git a/app/Notifications/MemberNewRequestMemberNotification.php b/app/Notifications/MemberNewRequestMemberNotification.php
index f93d758..56d2724 100644
--- a/app/Notifications/MemberNewRequestMemberNotification.php
+++ b/app/Notifications/MemberNewRequestMemberNotification.php
@@ -34,7 +34,7 @@ class MemberNewRequestMemberNotification extends Notification implements ShouldQ
$vars = [
'member_name' => $this->member->full_name,
'package_name' => $this->package->name,
- 'app_name' => 'Le Retzien Libre',
+ 'app_name' => config('app.front_name'),
];
return (new MailMessage)
diff --git a/app/Notifications/MembershipValidatedNotification.php b/app/Notifications/MembershipValidatedNotification.php
index a083f1d..3df081e 100644
--- a/app/Notifications/MembershipValidatedNotification.php
+++ b/app/Notifications/MembershipValidatedNotification.php
@@ -4,6 +4,7 @@ namespace App\Notifications;
use App\Models\Membership;
use App\Models\NotificationTemplate;
+use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
@@ -30,9 +31,9 @@ class MembershipValidatedNotification extends Notification implements ShouldQueu
$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'),
+ 'start_date' => $this->membership->start_date ? Carbon::parse($this->membership->start_date)->format('d/m/Y') : '',
+ 'end_date' => $this->membership->end_date ? Carbon::parse($this->membership->end_date)->format('d/m/Y') : '',
+ 'app_name' => config('app.front_name'),
];
return (new MailMessage)
diff --git a/app/Notifications/SubscriptionExpiredPhase1.php b/app/Notifications/SubscriptionExpiredPhase1.php
index ee9c634..8d97509 100644
--- a/app/Notifications/SubscriptionExpiredPhase1.php
+++ b/app/Notifications/SubscriptionExpiredPhase1.php
@@ -35,6 +35,7 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
->subject($this->template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $this->template->renderBody($vars),
+ 'appName' => config('app.front_name'),
]);
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 64b29d1..f5704e6 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -5,8 +5,11 @@ namespace App\Providers;
use App\Listeners\PreprodMailInterceptor;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Mail\Events\MessageSending;
+use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
+use Queue;
class AppServiceProvider extends ServiceProvider
{
@@ -26,5 +29,13 @@ class AppServiceProvider extends ServiceProvider
JsonResource::withoutWrapping();
Event::listen(MessageSending::class, PreprodMailInterceptor::class);
+
+ // Log failed jobs
+ Queue::failing(function (JobFailed $event) {
+ Log::error('Job failed: '.$event->job->resolveName(), [
+ 'exception' => $event->exception->getMessage(),
+ 'trace' => $event->exception->getTraceAsString(),
+ ]);
+ });
}
}
diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php
index 03b3f22..2571b09 100644
--- a/app/Services/MemberService.php
+++ b/app/Services/MemberService.php
@@ -45,16 +45,17 @@ class MemberService
->firstOrFail();
// Create a new membership
- $member->memberships()->create([
+ $membership = $member->memberships()->create([
'status' => 'pending',
'package_id' => $package->id ?? null,
'amount' => $data['amount'],
'payment_status' => 'unpaid',
-
]);
+ $membershipUrl = route('filament.admin.resources.memberships.edit', ['record' => $membership->id]);
+
Notification::route('mail', config('app.admin_email'))
- ->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount']));
+ ->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount'], $membershipUrl));
$member->notify(new MemberNewRequestMemberNotification($member, $package));
diff --git a/config/app.php b/config/app.php
index c3344aa..59979ca 100644
--- a/config/app.php
+++ b/config/app.php
@@ -14,6 +14,7 @@ return [
*/
'name' => env('APP_NAME', 'Laravel'),
+ 'front_name' => env('FRONT_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
diff --git a/database/seeders/NotificationTemplateSeeder.php b/database/seeders/NotificationTemplateSeeder.php
index da30ed4..6b6b5db 100644
--- a/database/seeders/NotificationTemplateSeeder.php
+++ b/database/seeders/NotificationTemplateSeeder.php
@@ -100,7 +100,7 @@ class NotificationTemplateSeeder extends Seeder
.'Formule : {package_name}
'
.'Montant : {amount} €'
.'
'
- .'Voir la fiche adhérent
',
+ .'Voir la demande d\'adhésion
',
'variables' => [
'member_name' => 'Nom complet du membre',
'member_email' => 'Adresse email du membre',
@@ -108,7 +108,7 @@ class NotificationTemplateSeeder extends Seeder
'member_address' => 'Adresse postale du membre',
'package_name' => 'Nom de la formule choisie',
'amount' => 'Montant de la cotisation',
- 'member_url' => 'URL de la fiche dans le back office',
+ 'membership_url' => 'URL de la fiche d\'adhésion dans le back office',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
diff --git a/lang/en/memberships.php b/lang/en/memberships.php
index 17ea723..65eb3b3 100644
--- a/lang/en/memberships.php
+++ b/lang/en/memberships.php
@@ -45,6 +45,7 @@ return [
'ispconfig_web' => 'Web Hosting',
'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk',
+ 'author_not_set' => 'Awaiting author',
],
'actions' => [
diff --git a/lang/fr/memberships.php b/lang/fr/memberships.php
index 7639e61..69e2928 100644
--- a/lang/fr/memberships.php
+++ b/lang/fr/memberships.php
@@ -45,6 +45,7 @@ return [
'ispconfig_web' => 'Hébergements Web',
'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk',
+ 'author_not_set' => 'En attente de l\'auteur',
],
'actions' => [
diff --git a/resources/js/pages/forms/membership.tsx b/resources/js/pages/forms/membership.tsx
index 261a554..aee38bc 100644
--- a/resources/js/pages/forms/membership.tsx
+++ b/resources/js/pages/forms/membership.tsx
@@ -21,6 +21,16 @@ export default function Membership() {
const [showFlashMessage, setFlashMessage] = useState(!!flash);
const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null);
const [amount, setAmount] = useState(plans?.[0]?.price ?? 0);
+ const [phone1Value, setPhone1Value] = useState('');
+ const [zipcodeValue, setZipcodeValue] = useState('');
+
+ const phone1LocalError = phone1Value.length > 0 && !/^\d{10}$/.test(phone1Value)
+ ? 'Le numéro doit contenir exactement 10 chiffres.'
+ : null;
+
+ const zipcodeLocalError = zipcodeValue.length > 0 && !/^\d{5}$/.test(zipcodeValue)
+ ? 'Le code postal doit contenir exactement 5 chiffres.'
+ : null;
useEffect(() => {
if (plans && selectedPlan) {
@@ -98,8 +108,20 @@ export default function Membership() {
-
-
+ setPhone1Value(e.target.value.replace(/\D/g, '').slice(0, 10))}
+ />
+
@@ -117,8 +139,20 @@ export default function Membership() {