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() {
- - + setZipcodeValue(e.target.value.replace(/\D/g, '').slice(0, 5))} + /> +
diff --git a/resources/views/notifications/mail-template.blade.php b/resources/views/notifications/mail-template.blade.php index 625fd0e..92419a5 100644 --- a/resources/views/notifications/mail-template.blade.php +++ b/resources/views/notifications/mail-template.blade.php @@ -56,13 +56,13 @@
-

{{ config('app.name') }}

+

{{ $appName ?? config('app.name') }}

{!! $body !!}