feat(Notification process: text, app name, membership validation + project update)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s

This commit is contained in:
2026-07-18 15:19:48 +02:00
parent b4845511a6
commit 81dbe4275d
18 changed files with 142 additions and 52 deletions

View File

@@ -1,4 +1,5 @@
APP_NAME="Roxane - Le Retzien Libre" APP_NAME="Roxane - Le Retzien Libre"
FRONT_NAME="Le Retzien Libre"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true

View File

@@ -165,9 +165,24 @@ resources/js/components/
## Notifications ## Notifications
| Classe | Canal | Description | 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.
| ------------------------- | ----- | --------------------------------------------- |
| SubscriptionExpiredPhase1 | Email | Notification d'expiration d'adhesion (queued) | | 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`)
--- ---
@@ -180,14 +195,15 @@ Langues : **fr**, **en** — fichiers dans `lang/{locale}/` : contacts, members,
## TODOs identifies dans le code ## TODOs identifies dans le code
| Fichier | TODO | | Fichier | TODO |
|---------------------------|----------------------------------------------------------------------------| |--------------------------------|--------------------------------------------------------------------------------|
| ContactService | Envoyer un email a l'administrateur | | Notifications (x9) | Propager `appName` dans `->view()` pour toutes les notifications restantes |
| MemberService | Envoyer des emails au membre + admin a la desactivation | | SubscriptionExpiredPhase1 | UI backend pour éditer le contenu du template de notification |
| SubscriptionExpiredPhase1 | Creer un template generique + UI backend pour le contenu |
| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr | | User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper | | SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
| SyncISPConfigMailMembers | Gerer plusieurs emails par membre | | SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id | | 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) | | Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
| Traduction | Crawler le prrojet pour retrouver toutes les clés manquantes | | Traduction | Crawler le projet pour retrouver toutes les clés manquantes |
| Global | PHPstan niveau 8 | | Global | PHPstan niveau 8 |
| dev-routes.php | Supprimer la route de test mail (/test/mail) avant mise en production |

View File

@@ -12,17 +12,24 @@ The project is developed in the context of **Le Retzien Libre**, a non-profit as
**Back office (administrators)** **Back office (administrators)**
- Member management (status, nature, groups) - 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 - Manual and automated synchronization with third-party services
- Role and permission management (Spatie Permissions + Filament Shield) - Role and permission management (Spatie Permissions + Filament Shield)
- Two-factor authentication for admin accounts - Two-factor authentication for admin accounts
**Front office (members)** **Front office (members)**
- Registration and membership form - Registration and membership form with real-time field validation
- Personal dashboard with access to associated services - Personal dashboard with access to associated services
- Profile and password management - Profile and password management
- Two-factor authentication - 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** **Integrations**
- Dolibarr ERP (member and subscription import via REST API) - Dolibarr ERP (member and subscription import via REST API)
- ISPConfig (mail and web hosting account management via SOAP) - 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 ### 1. Clone the repository
```bash ```bash
git clone https://github.com/your-org/roxane.git git clone https://github.com/SabrinaNebulae/roxane.git
cd roxane cd roxane
``` ```
@@ -87,6 +94,8 @@ Edit `.env` and configure:
- Database connection (`DB_*`) - Database connection (`DB_*`)
- Redis connection (`REDIS_*`) - Redis connection (`REDIS_*`)
- Mail configuration (`MAIL_*`) - 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) - Third-party service credentials (Dolibarr, ISPConfig, Nextcloud)
### 4. Database setup ### 4. Database setup
@@ -187,10 +196,10 @@ php artisan test --compact
## Known TODOs ## Known TODOs
| Area | Description | | Area | Description |
|---------------------------|---------------------------------------------------------------------| |-----------------------------|----------------------------------------------------------------------------------|
| ContactService | Send email notification to administrator on new contact request | | Notifications | Propagate `appName` view variable to all remaining notifications |
| MemberService | Send emails to member and admin on deactivation | | SubscriptionExpiredPhase1 | Backend UI for editing notification template content |
| SubscriptionExpiredPhase1 | Generic template + backend UI for notification content management | | MembershipValidatedNotif. | Add HelloAsso payment link once integration is available |
| User.php | Restrict admin access in production to @retzien.fr emails | | User.php | Restrict admin access in production to @retzien.fr emails |
| SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper | | SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper |
| SyncISPConfigMailMembers | Handle multiple email addresses per member | | SyncISPConfigMailMembers | Handle multiple email addresses per member |
@@ -198,6 +207,7 @@ php artisan test --compact
| Global | Make Roxane fully generic for any association ERP use case | | Global | Make Roxane fully generic for any association ERP use case |
| Translations | Audit project for missing translation keys | | Translations | Audit project for missing translation keys |
| Global | Raise PHPStan to level 8 | | 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 | | V2 | Keycloak SSO integration (OIDC) for front office authentication |
--- ---

View File

@@ -57,7 +57,8 @@ class MembershipForm
TextEntry::make('member.full_name') TextEntry::make('member.full_name')
->label(Membership::getAttributeLabel('member_id')), ->label(Membership::getAttributeLabel('member_id')),
TextEntry::make('author.name') TextEntry::make('author.name')
->label(Membership::getAttributeLabel('admin_id')), ->label(Membership::getAttributeLabel('admin_id'))
->default(__('memberships.sections.author_not_set')),
TextEntry::make('created_at') TextEntry::make('created_at')
->label(Membership::getAttributeLabel('created_at')), ->label(Membership::getAttributeLabel('created_at')),
]) ])

View File

@@ -12,6 +12,17 @@ class MembershipRequest extends FormRequest
return true; return true;
} }
/**
* @return array<string, string>
*/
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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/ */
@@ -23,9 +34,9 @@ class MembershipRequest extends FormRequest
'email' => 'required|email|max:255', 'email' => 'required|email|max:255',
'company' => 'nullable|string|max:255', 'company' => 'nullable|string|max:255',
'address' => 'required|string|max:255', 'address' => 'required|string|max:255',
'zipcode' => 'required|string|max:255', 'zipcode' => ['required', 'digits:5'],
'city' => 'required|string|max:255', 'city' => 'required|string|max:255',
'phone1' => 'required|string|max:255', 'phone1' => ['required', 'digits:10'],
'package' => 'required|string|max:255', 'package' => 'required|string|max:255',
'amount' => 'required|numeric|min:0', 'amount' => 'required|numeric|min:0',
'cgu' => 'required|accepted', 'cgu' => 'required|accepted',

View File

@@ -29,7 +29,7 @@ class MemberDeactivatedMemberNotification extends Notification implements Should
$vars = [ $vars = [
'member_name' => $this->member->full_name, 'member_name' => $this->member->full_name,
'app_name' => config('app.name'), 'app_name' => config('app.front_name'),
]; ];
return (new MailMessage) return (new MailMessage)

View File

@@ -2,7 +2,6 @@
namespace App\Notifications; namespace App\Notifications;
use App\Filament\Resources\Members\MemberResource;
use App\Models\Member; use App\Models\Member;
use App\Models\NotificationTemplate; use App\Models\NotificationTemplate;
use App\Models\Package; use App\Models\Package;
@@ -19,6 +18,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
public readonly Member $member, public readonly Member $member,
public readonly Package $package, public readonly Package $package,
public readonly float $amount, public readonly float $amount,
public readonly string $membershipUrl,
) {} ) {}
/** /**
@@ -44,7 +44,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
])), ])),
'package_name' => $this->package->name, 'package_name' => $this->package->name,
'amount' => number_format($this->amount, 2, ',', ' '), 'amount' => number_format($this->amount, 2, ',', ' '),
'member_url' => MemberResource::getUrl('edit', ['record' => $this->member->id]), 'membership_url' => $this->membershipUrl,
'app_name' => config('app.name'), 'app_name' => config('app.name'),
]; ];

View File

@@ -34,7 +34,7 @@ class MemberNewRequestMemberNotification extends Notification implements ShouldQ
$vars = [ $vars = [
'member_name' => $this->member->full_name, 'member_name' => $this->member->full_name,
'package_name' => $this->package->name, 'package_name' => $this->package->name,
'app_name' => 'Le Retzien Libre', 'app_name' => config('app.front_name'),
]; ];
return (new MailMessage) return (new MailMessage)

View File

@@ -4,6 +4,7 @@ namespace App\Notifications;
use App\Models\Membership; use App\Models\Membership;
use App\Models\NotificationTemplate; use App\Models\NotificationTemplate;
use Carbon\Carbon;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@@ -30,9 +31,9 @@ class MembershipValidatedNotification extends Notification implements ShouldQueu
$vars = [ $vars = [
'member_name' => $this->membership->member->full_name, 'member_name' => $this->membership->member->full_name,
'package_name' => $this->membership->package->name, 'package_name' => $this->membership->package->name,
'start_date' => $this->membership->start_date?->format('d/m/Y') ?? '', 'start_date' => $this->membership->start_date ? Carbon::parse($this->membership->start_date)->format('d/m/Y') : '',
'end_date' => $this->membership->end_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.name'), 'app_name' => config('app.front_name'),
]; ];
return (new MailMessage) return (new MailMessage)

View File

@@ -35,6 +35,7 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
->subject($this->template->renderSubject($vars)) ->subject($this->template->renderSubject($vars))
->view('notifications.mail-template', [ ->view('notifications.mail-template', [
'body' => $this->template->renderBody($vars), 'body' => $this->template->renderBody($vars),
'appName' => config('app.front_name'),
]); ]);
} }

View File

@@ -5,8 +5,11 @@ namespace App\Providers;
use App\Listeners\PreprodMailInterceptor; use App\Listeners\PreprodMailInterceptor;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSending;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Queue;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -26,5 +29,13 @@ class AppServiceProvider extends ServiceProvider
JsonResource::withoutWrapping(); JsonResource::withoutWrapping();
Event::listen(MessageSending::class, PreprodMailInterceptor::class); 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(),
]);
});
} }
} }

View File

@@ -45,16 +45,17 @@ class MemberService
->firstOrFail(); ->firstOrFail();
// Create a new membership // Create a new membership
$member->memberships()->create([ $membership = $member->memberships()->create([
'status' => 'pending', 'status' => 'pending',
'package_id' => $package->id ?? null, 'package_id' => $package->id ?? null,
'amount' => $data['amount'], 'amount' => $data['amount'],
'payment_status' => 'unpaid', 'payment_status' => 'unpaid',
]); ]);
$membershipUrl = route('filament.admin.resources.memberships.edit', ['record' => $membership->id]);
Notification::route('mail', config('app.admin_email')) 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)); $member->notify(new MemberNewRequestMemberNotification($member, $package));

View File

@@ -14,6 +14,7 @@ return [
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Laravel'),
'front_name' => env('FRONT_NAME', 'Laravel'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -100,7 +100,7 @@ class NotificationTemplateSeeder extends Seeder
.'<strong>Formule :</strong> {package_name}<br>' .'<strong>Formule :</strong> {package_name}<br>'
.'<strong>Montant :</strong> {amount} €' .'<strong>Montant :</strong> {amount} €'
.'</p>' .'</p>'
.'<p><a href="{member_url}" style="display:inline-block;padding:10px 20px;background:#f5a623;color:#000;font-weight:bold;text-decoration:none;border:3px solid #000;border-radius:6px;">Voir la fiche adhérent</a></p>', .'<p><a href="{membership_url}" style="display:inline-block;padding:10px 20px;background:#f48fb1;color:#000;font-weight:bold;text-decoration:none;border:3px solid #000;border-radius:6px;">Voir la demande d\'adhésion</a></p>',
'variables' => [ 'variables' => [
'member_name' => 'Nom complet du membre', 'member_name' => 'Nom complet du membre',
'member_email' => 'Adresse email du membre', 'member_email' => 'Adresse email du membre',
@@ -108,7 +108,7 @@ class NotificationTemplateSeeder extends Seeder
'member_address' => 'Adresse postale du membre', 'member_address' => 'Adresse postale du membre',
'package_name' => 'Nom de la formule choisie', 'package_name' => 'Nom de la formule choisie',
'amount' => 'Montant de la cotisation', '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', 'app_name' => 'Nom de l\'application',
], ],
'is_active' => true, 'is_active' => true,

View File

@@ -45,6 +45,7 @@ return [
'ispconfig_web' => 'Web Hosting', 'ispconfig_web' => 'Web Hosting',
'nextcloud' => 'NextCloud', 'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk', 'listmonk' => 'Listmonk',
'author_not_set' => 'Awaiting author',
], ],
'actions' => [ 'actions' => [

View File

@@ -45,6 +45,7 @@ return [
'ispconfig_web' => 'Hébergements Web', 'ispconfig_web' => 'Hébergements Web',
'nextcloud' => 'NextCloud', 'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk', 'listmonk' => 'Listmonk',
'author_not_set' => 'En attente de l\'auteur',
], ],
'actions' => [ 'actions' => [

View File

@@ -21,6 +21,16 @@ export default function Membership() {
const [showFlashMessage, setFlashMessage] = useState(!!flash); const [showFlashMessage, setFlashMessage] = useState(!!flash);
const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null); const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null);
const [amount, setAmount] = useState(plans?.[0]?.price ?? 0); 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(() => { useEffect(() => {
if (plans && selectedPlan) { if (plans && selectedPlan) {
@@ -98,8 +108,20 @@ export default function Membership() {
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="phone1">Téléphone*</Label> <Label htmlFor="phone1">Téléphone*</Label>
<Input id="phone1" name="phone1" type="tel" required tabIndex={4} autoComplete="tel" placeholder="Votre numéro de téléphone" /> <Input
<InputError message={errors.phone1} /> id="phone1"
name="phone1"
type="tel"
required
tabIndex={4}
autoComplete="tel"
inputMode="numeric"
maxLength={10}
placeholder="0612345678"
value={phone1Value}
onChange={(e) => setPhone1Value(e.target.value.replace(/\D/g, '').slice(0, 10))}
/>
<InputError message={phone1LocalError ?? errors.phone1} />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
@@ -117,8 +139,20 @@ export default function Membership() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="zipcode">Code postal*</Label> <Label htmlFor="zipcode">Code postal*</Label>
<Input id="zipcode" name="zipcode" type="text" required tabIndex={7} autoComplete="postal-code" placeholder="Code postal" /> <Input
<InputError message={errors.zipcode} /> id="zipcode"
name="zipcode"
type="text"
required
tabIndex={7}
autoComplete="postal-code"
inputMode="numeric"
maxLength={5}
placeholder="44000"
value={zipcodeValue}
onChange={(e) => setZipcodeValue(e.target.value.replace(/\D/g, '').slice(0, 5))}
/>
<InputError message={zipcodeLocalError ?? errors.zipcode} />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="city">Ville*</Label> <Label htmlFor="city">Ville*</Label>

View File

@@ -56,13 +56,13 @@
<body> <body>
<div class="wrapper"> <div class="wrapper">
<div class="header"> <div class="header">
<h1>{{ config('app.name') }}</h1> <h1>{{ $appName ?? config('app.name') }}</h1>
</div> </div>
<div class="content"> <div class="content">
{!! $body !!} {!! $body !!}
</div> </div>
<div class="footer"> <div class="footer">
&copy; {{ date('Y') }} {{ config('app.name') }}. Tous droits réservés. &copy; {{ date('Y') }} {{ $appName ?? config('app.name') }}. Tous droits réservés.
</div> </div>
</div> </div>
</body> </body>