feat(Mail template & Membership relationship)

This commit is contained in:
2026-02-16 14:16:52 +01:00
parent 45920c083e
commit 6e73c82787
37 changed files with 1374 additions and 169 deletions

View File

@@ -2,35 +2,13 @@
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use App\Models\Member;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MemberRegistered
{
use Dispatchable, InteractsWithSockets, SerializesModels;
use Dispatchable, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
public function __construct(public readonly Member $member) {}
}

View File

@@ -2,35 +2,13 @@
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use App\Models\Member;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MemberValidated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
use Dispatchable, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
public function __construct(public readonly Member $member) {}
}

View File

@@ -36,7 +36,7 @@ class MemberResource extends Resource
public static function getRelations(): array
{
return [
//
RelationManagers\MembershipsRelationManager::class,
];
}
@@ -65,5 +65,4 @@ class MemberResource extends Resource
MemberCount::class,
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources\Members\RelationManagers;
use App\Models\Membership;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class MembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
protected static ?string $title = 'Adhésions';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('start_date')
->columns([
TextColumn::make('start_date')
->label(Membership::getAttributeLabel('start_date'))
->date()
->sortable(),
TextColumn::make('end_date')
->label(Membership::getAttributeLabel('end_date'))
->date()
->sortable(),
TextColumn::make('status')
->label(Membership::getAttributeLabel('status'))
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'expired' => 'danger',
'pending' => 'warning',
}),
TextColumn::make('package.name')
->label(Membership::getAttributeLabel('package_id')),
TextColumn::make('amount')
->label(Membership::getAttributeLabel('amount'))
->money('EUR')
->sortable(),
TextColumn::make('payment_status')
->label(Membership::getAttributeLabel('payment_status'))
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
->badge()
->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'unpaid' => 'danger',
'partial' => 'warning',
}),
TextColumn::make('created_at')
->label(Membership::getAttributeLabel('created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('start_date', 'desc')
->headerActions([
CreateAction::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\NotificationTemplates;
use App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate;
use App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate;
use App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates;
use App\Filament\Resources\NotificationTemplates\Schemas\NotificationTemplateForm;
use App\Filament\Resources\NotificationTemplates\Tables\NotificationTemplatesTable;
use App\Models\NotificationTemplate;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class NotificationTemplateResource extends Resource
{
protected static ?string $model = NotificationTemplate::class;
protected static string|null|\UnitEnum $navigationGroup = 'Administration';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedEnvelope;
public static function getModelLabel(): string
{
return NotificationTemplate::getAttributeLabel('singular_name');
}
public static function getPluralModelLabel(): string
{
return NotificationTemplate::getAttributeLabel('plural_name');
}
public static function form(Schema $schema): Schema
{
return NotificationTemplateForm::configure($schema);
}
public static function table(Table $table): Table
{
return NotificationTemplatesTable::configure($table);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => ListNotificationTemplates::route('/'),
'create' => CreateNotificationTemplate::route('/create'),
'edit' => EditNotificationTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\NotificationTemplates\Pages;
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateNotificationTemplate extends CreateRecord
{
protected static string $resource = NotificationTemplateResource::class;
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\NotificationTemplates\Pages;
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Resources\Pages\EditRecord;
class EditNotificationTemplate extends EditRecord
{
protected static string $resource = NotificationTemplateResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\NotificationTemplates\Pages;
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListNotificationTemplates extends ListRecords
{
protected static string $resource = NotificationTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Filament\Resources\NotificationTemplates\Schemas;
use App\Models\NotificationTemplate;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class NotificationTemplateForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(fn (?NotificationTemplate $record) => $record?->name ?? NotificationTemplate::getAttributeLabel('name'))
->afterHeader([
Toggle::make('is_active')
->label(NotificationTemplate::getAttributeLabel('is_active'))
->default(true),
])
->schema([
TextInput::make('name')
->label(NotificationTemplate::getAttributeLabel('name'))
->required(),
TextInput::make('identifier')
->label(NotificationTemplate::getAttributeLabel('identifier'))
->required()
->disabledOn('edit'),
TextInput::make('subject')
->label(NotificationTemplate::getAttributeLabel('subject'))
->required()
->helperText('Variables : {member_name}, {expiry_date}'),
RichEditor::make('body')
->label(NotificationTemplate::getAttributeLabel('body'))
->required()
->helperText('Variables : {member_name}, {expiry_date}')
->columnSpanFull(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\NotificationTemplates\Tables;
use App\Models\NotificationTemplate;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class NotificationTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(NotificationTemplate::getAttributeLabel('name'))
->searchable(),
TextColumn::make('identifier')
->label(NotificationTemplate::getAttributeLabel('identifier'))
->searchable(),
TextColumn::make('subject')
->label(NotificationTemplate::getAttributeLabel('subject'))
->searchable(),
IconColumn::make('is_active')
->label(NotificationTemplate::getAttributeLabel('is_active'))
->boolean(),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Notifications\SubscriptionExpiredPhase1;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
@@ -11,22 +12,21 @@ class SendSubscriptionExpiredPhase1Notifications implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
}
public function __construct() {}
/**
* Execute the job.
*/
public function handle(): void
{
Member::isExpired()
->chunk(100, function ($members) {
$template = NotificationTemplate::findByIdentifier('subscription_expired_phase1');
if (! $template) {
return;
}
Member::query()
->whereHas('memberships', fn ($query) => $query->where('status', 'expired'))
->chunk(100, function ($members) use ($template) {
foreach ($members as $member) {
$member->notify(new SubscriptionExpiredPhase1());
$member->notify(new SubscriptionExpiredPhase1($template));
}
});
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\MemberValidated;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class NotifiyMemberOfValidation
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(MemberValidated $event): void
{
//
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\MemberRegistered;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class NotifyAdminForMembershipRequest
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(MemberRegistered $event): void
{
$admin = User::where('name', 'SuperAdmin')->first();
$admin->notify(new AdminNewUserPending($event->user));
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Models;
use App\Enums\IspconfigType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -46,6 +45,7 @@ use Illuminate\Notifications\Notifiable;
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \App\Models\User|null $user
*
* @method static \Database\Factories\MemberFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newQuery()
@@ -75,11 +75,13 @@ use Illuminate\Notifications\Notifiable;
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereWebsiteUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereZipcode($value)
*
* @mixin \Eloquent
*/
class Member extends Model
{
use HasFactory, Notifiable;
protected $fillable = [
'user_id',
'dolibarr_id',
@@ -100,7 +102,7 @@ class Member extends Model
'phone1',
'phone2',
'public_membership',
'website_url'
'website_url',
];
public static function getAttributeLabel(string $attribute): string
@@ -113,10 +115,11 @@ class Member extends Model
return "{$this->firstname} {$this->lastname}";
}
public function getRetzienEmailAttribute(): string
public function getRetzienEmailAttribute(): ?string
{
$emails = explode(';', $this->email);
return collect($emails)->filter(fn($email) => str_contains($email, '@retzien.fr'))->first();
$emails = explode(';', $this->email);
return collect($emails)->filter(fn ($email) => str_contains($email, '@retzien.fr'))->first();
}
public function user(): BelongsTo
@@ -152,6 +155,7 @@ class Member extends Model
public function hasService(string $serviceIdentifier): bool
{
$membership = $this->lastMembership();
return $membership->services()->where('identifier', $serviceIdentifier)->exists();
}
@@ -159,7 +163,7 @@ class Member extends Model
{
// Member ayant leur dernière adhésion non renouvellée de puis plus d'un mois
$lastMembership = $this->lastMembership();
return $lastMembership->status === 'expired' || $lastMembership->created_at->addMonths(1) < now();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class NotificationTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'identifier',
'name',
'subject',
'body',
'variables',
'is_active',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'variables' => 'array',
'is_active' => 'boolean',
];
}
public static function getAttributeLabel(string $attribute): string
{
return __('notification_templates.fields.'.$attribute);
}
public static function findByIdentifier(string $identifier): ?self
{
return self::query()
->where('identifier', $identifier)
->where('is_active', true)
->first();
}
public function renderSubject(array $vars): string
{
return $this->replacePlaceholders($this->subject, $vars);
}
public function renderBody(array $vars): string
{
return $this->replacePlaceholders($this->body, $vars);
}
private function replacePlaceholders(string $text, array $vars): string
{
foreach ($vars as $key => $value) {
$text = str_replace('{'.$key.'}', (string) $value, $text);
}
return $text;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Notifications;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
@@ -11,17 +12,9 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct()
{
//
}
public function __construct(public readonly NotificationTemplate $template) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
@@ -29,30 +22,27 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
//@todo: créer template générique + espace dans le BO pour alimenter les texte
$lastMembership = $notifiable->memberships()->latest()->first();
$vars = [
'member_name' => $notifiable->full_name,
'expiry_date' => $lastMembership?->end_date ?? '',
];
return (new MailMessage)
->subject('Votre adhésion est expirée')
->greeting('Bonjour ' . $notifiable->name)
->line('Votre adhésion est arrivée à expiration.')
->line('Pour continuer à profiter nos services, merci de le renouveler.')
->action('Renouveler mon adhésion', url('/devenir-membre'))
->line('Merci pour votre confiance.');
->subject($this->template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $this->template->renderBody($vars),
]);
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
return [];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\NotificationTemplate;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Foundation\Auth\User as AuthUser;
class NotificationTemplatePolicy
{
use HandlesAuthorization;
public function viewAny(AuthUser $authUser): bool
{
return $authUser->can('ViewAny:NotificationTemplate');
}
public function view(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('View:NotificationTemplate');
}
public function create(AuthUser $authUser): bool
{
return $authUser->can('Create:NotificationTemplate');
}
public function update(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('Update:NotificationTemplate');
}
public function delete(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('Delete:NotificationTemplate');
}
public function restore(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('Restore:NotificationTemplate');
}
public function forceDelete(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('ForceDelete:NotificationTemplate');
}
public function forceDeleteAny(AuthUser $authUser): bool
{
return $authUser->can('ForceDeleteAny:NotificationTemplate');
}
public function restoreAny(AuthUser $authUser): bool
{
return $authUser->can('RestoreAny:NotificationTemplate');
}
public function replicate(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
{
return $authUser->can('Replicate:NotificationTemplate');
}
public function reorder(AuthUser $authUser): bool
{
return $authUser->can('Reorder:NotificationTemplate');
}
}

View File

@@ -11,17 +11,15 @@ class MemberService
{
/**
* Register a new member.
* @param array $data
* @return Member
*/
public function registerNewMember(array $data): Member
{
// Check if the member already exists
$member = Member::where('email', $data['email'])->first();
if (!$member) {
if (! $member) {
// Create a new member
$member = new Member();
$member = new Member;
$member->status = 'pending';
$member->nature = 'physical';
$member->group_id = MemberGroup::where('identifier', 'website')->first()->id ?? null;
@@ -50,10 +48,7 @@ class MemberService
]);
// Notify Admin
$admin = Member::where('role', 'admin')->first();
event(new MemberRegistered($admin));
event(new MemberRegistered($member));
return $member;
}