feat(Notification process: text, app name, membership validation + project update)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
APP_NAME="Roxane - Le Retzien Libre"
|
||||
FRONT_NAME="Le Retzien Libre"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -180,14 +195,15 @@ 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 |
|
||||
|--------------------------------|--------------------------------------------------------------------------------|
|
||||
| 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 prrojet pour retrouver toutes les clés manquantes |
|
||||
| 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 |
|
||||
|
||||
24
README.md
24
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
|
||||
@@ -187,10 +196,10 @@ 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 |
|
||||
|-----------------------------|----------------------------------------------------------------------------------|
|
||||
| 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 |
|
||||
@@ -198,6 +207,7 @@ php artisan test --compact
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
@@ -12,6 +12,17 @@ class MembershipRequest extends FormRequest
|
||||
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>
|
||||
*/
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ return [
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
'front_name' => env('FRONT_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -100,7 +100,7 @@ class NotificationTemplateSeeder extends Seeder
|
||||
.'<strong>Formule :</strong> {package_name}<br>'
|
||||
.'<strong>Montant :</strong> {amount} €'
|
||||
.'</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' => [
|
||||
'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,
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'ispconfig_web' => 'Web Hosting',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
'author_not_set' => 'Awaiting author',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'ispconfig_web' => 'Hébergements Web',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
'author_not_set' => 'En attente de l\'auteur',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<div className="grid gap-1">
|
||||
<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" />
|
||||
<InputError message={errors.phone1} />
|
||||
<Input
|
||||
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 className="grid gap-1">
|
||||
@@ -117,8 +139,20 @@ export default function Membership() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="zipcode">Code postal*</Label>
|
||||
<Input id="zipcode" name="zipcode" type="text" required tabIndex={7} autoComplete="postal-code" placeholder="Code postal" />
|
||||
<InputError message={errors.zipcode} />
|
||||
<Input
|
||||
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 className="grid gap-1">
|
||||
<Label htmlFor="city">Ville*</Label>
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="header">
|
||||
<h1>{{ config('app.name') }}</h1>
|
||||
<h1>{{ $appName ?? config('app.name') }}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{!! $body !!}
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{ date('Y') }} {{ config('app.name') }}. Tous droits réservés.
|
||||
© {{ date('Y') }} {{ $appName ?? config('app.name') }}. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user