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

@@ -1,6 +1,6 @@
Tu es un agent IA senior specialise en Laravel, Filament, React et Tailwind. Tu es un agent IA senior specialise en Laravel, Filament, React et Tailwind.
Tu respectes STRICTEMENT les regles du fichier .ai-rules.md. Tu respectes STRICTEMENT les regles du fichier .ai-rules.md et tu parcours bien le ficher .PROJECT_STRUCTURE.md pour avoir le contexte du projet.
Ton role : Ton role :
- proposer des solutions simples et maintenables - proposer des solutions simples et maintenables

View File

@@ -3,7 +3,13 @@
"allow": [ "allow": [
"mcp__laravel-boost__application-info", "mcp__laravel-boost__application-info",
"mcp__laravel-boost__database-schema", "mcp__laravel-boost__database-schema",
"mcp__laravel-boost__list-routes" "mcp__laravel-boost__list-routes",
"Bash(php artisan make:migration:*)",
"mcp__laravel-boost__search-docs",
"Bash(vendor/bin/pint:*)",
"Bash(php artisan test:*)",
"Bash(php artisan migrate:*)",
"Bash(php artisan make:filament-relation-manager:*)"
] ]
}, },
"enableAllProjectMcpServers": true, "enableAllProjectMcpServers": true,

View File

@@ -2,35 +2,13 @@
namespace App\Events; namespace App\Events;
use Illuminate\Broadcasting\Channel; use App\Models\Member;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class MemberRegistered class MemberRegistered
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, SerializesModels;
/** public function __construct(public readonly Member $member) {}
* 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'),
];
}
} }

View File

@@ -2,35 +2,13 @@
namespace App\Events; namespace App\Events;
use Illuminate\Broadcasting\Channel; use App\Models\Member;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class MemberValidated class MemberValidated
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, SerializesModels;
/** public function __construct(public readonly Member $member) {}
* 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'),
];
}
} }

View File

@@ -36,7 +36,7 @@ class MemberResource extends Resource
public static function getRelations(): array public static function getRelations(): array
{ {
return [ return [
// RelationManagers\MembershipsRelationManager::class,
]; ];
} }
@@ -65,5 +65,4 @@ class MemberResource extends Resource
MemberCount::class, 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; namespace App\Jobs;
use App\Models\Member; use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Notifications\SubscriptionExpiredPhase1; use App\Notifications\SubscriptionExpiredPhase1;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
@@ -11,22 +12,21 @@ class SendSubscriptionExpiredPhase1Notifications implements ShouldQueue
{ {
use Queueable; use Queueable;
/** public function __construct() {}
* Create a new job instance.
*/
public function __construct()
{
}
/**
* Execute the job.
*/
public function handle(): void public function handle(): void
{ {
Member::isExpired() $template = NotificationTemplate::findByIdentifier('subscription_expired_phase1');
->chunk(100, function ($members) {
if (! $template) {
return;
}
Member::query()
->whereHas('memberships', fn ($query) => $query->where('status', 'expired'))
->chunk(100, function ($members) use ($template) {
foreach ($members as $member) { 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; namespace App\Models;
use App\Enums\IspconfigType;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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 \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count * @property-read int|null $notifications_count
* @property-read \App\Models\User|null $user * @property-read \App\Models\User|null $user
*
* @method static \Database\Factories\MemberFactory factory($count = null, $state = []) * @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 newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newQuery() * @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 whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereWebsiteUrl($value) * @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereWebsiteUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereZipcode($value) * @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereZipcode($value)
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class Member extends Model class Member extends Model
{ {
use HasFactory, Notifiable; use HasFactory, Notifiable;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'dolibarr_id', 'dolibarr_id',
@@ -100,7 +102,7 @@ class Member extends Model
'phone1', 'phone1',
'phone2', 'phone2',
'public_membership', 'public_membership',
'website_url' 'website_url',
]; ];
public static function getAttributeLabel(string $attribute): string public static function getAttributeLabel(string $attribute): string
@@ -113,10 +115,11 @@ class Member extends Model
return "{$this->firstname} {$this->lastname}"; return "{$this->firstname} {$this->lastname}";
} }
public function getRetzienEmailAttribute(): string public function getRetzienEmailAttribute(): ?string
{ {
$emails = explode(';', $this->email); $emails = explode(';', $this->email);
return collect($emails)->filter(fn($email) => str_contains($email, '@retzien.fr'))->first();
return collect($emails)->filter(fn ($email) => str_contains($email, '@retzien.fr'))->first();
} }
public function user(): BelongsTo public function user(): BelongsTo
@@ -152,6 +155,7 @@ class Member extends Model
public function hasService(string $serviceIdentifier): bool public function hasService(string $serviceIdentifier): bool
{ {
$membership = $this->lastMembership(); $membership = $this->lastMembership();
return $membership->services()->where('identifier', $serviceIdentifier)->exists(); 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 // Member ayant leur dernière adhésion non renouvellée de puis plus d'un mois
$lastMembership = $this->lastMembership(); $lastMembership = $this->lastMembership();
return $lastMembership->status === 'expired' || $lastMembership->created_at->addMonths(1) < now(); 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; namespace App\Notifications;
use App\Models\NotificationTemplate;
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;
@@ -11,17 +12,9 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
{ {
use Queueable; use Queueable;
/** public function __construct(public readonly NotificationTemplate $template) {}
* Create a new notification instance.
*/
public function __construct()
{
//
}
/** /**
* Get the notification's delivery channels.
*
* @return array<int, string> * @return array<int, string>
*/ */
public function via(object $notifiable): array public function via(object $notifiable): array
@@ -29,30 +22,27 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
return ['mail']; return ['mail'];
} }
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage 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) return (new MailMessage)
->subject('Votre adhésion est expirée') ->subject($this->template->renderSubject($vars))
->greeting('Bonjour ' . $notifiable->name) ->view('notifications.mail-template', [
->line('Votre adhésion est arrivée à expiration.') 'body' => $this->template->renderBody($vars),
->line('Pour continuer à profiter nos services, merci de le renouveler.') ]);
->action('Renouveler mon adhésion', url('/devenir-membre'))
->line('Merci pour votre confiance.');
} }
/** /**
* Get the array representation of the notification.
*
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function toArray(object $notifiable): array 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. * Register a new member.
* @param array $data
* @return Member
*/ */
public function registerNewMember(array $data): Member public function registerNewMember(array $data): Member
{ {
// Check if the member already exists // Check if the member already exists
$member = Member::where('email', $data['email'])->first(); $member = Member::where('email', $data['email'])->first();
if (!$member) { if (! $member) {
// Create a new member // Create a new member
$member = new Member(); $member = new Member;
$member->status = 'pending'; $member->status = 'pending';
$member->nature = 'physical'; $member->nature = 'physical';
$member->group_id = MemberGroup::where('identifier', 'website')->first()->id ?? null; $member->group_id = MemberGroup::where('identifier', 'website')->first()->id ?? null;
@@ -50,10 +48,7 @@ class MemberService
]); ]);
// Notify Admin event(new MemberRegistered($member));
$admin = Member::where('role', 'admin')->first();
event(new MemberRegistered($admin));
return $member; return $member;
} }

View File

@@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\NotificationTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<NotificationTemplate>
*/
class NotificationTemplateFactory extends Factory
{
protected $model = NotificationTemplate::class;
public function definition(): array
{
return [
'identifier' => $this->faker->unique()->slug(2),
'name' => $this->faker->sentence(3),
'subject' => $this->faker->sentence(),
'body' => $this->faker->paragraph(),
'variables' => ['name' => 'Nom'],
'is_active' => true,
];
}
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notification_templates', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique();
$table->string('name');
$table->string('subject');
$table->longText('body');
$table->json('variables')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notification_templates');
}
};

View File

@@ -3,10 +3,9 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\MemberGroup; use App\Models\MemberGroup;
use App\Models\Package;
use App\Models\Service; use App\Models\Service;
use App\Models\User; use App\Models\User;
use App\Models\Package;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents; // use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -36,11 +35,11 @@ class DatabaseSeeder extends Seeder
]); ]);
$websiteGroup = MemberGroup::updateOrCreate([ $websiteGroup = MemberGroup::updateOrCreate([
'name' => 'Site Web' 'name' => 'Site Web',
], ],
[ [
'identifier' => 'website', 'identifier' => 'website',
'description' => 'Groupe d\'utilisateurs provenant du site web.' 'description' => 'Groupe d\'utilisateurs provenant du site web.',
]); ]);
// Subscription packages // Subscription packages
@@ -49,20 +48,20 @@ class DatabaseSeeder extends Seeder
'identifier' => 'custom', 'identifier' => 'custom',
'name' => 'Sur-mesure', 'name' => 'Sur-mesure',
'description' => 'Calcul du nombre de mois restant dans l\'année', 'description' => 'Calcul du nombre de mois restant dans l\'année',
'price' => '1.00' 'price' => '1.00',
], ],
[ [
'identifier' => 'one-year', 'identifier' => 'one-year',
'name' => 'Un an', 'name' => 'Un an',
'description' => '12 mois à compter de la date de validation de l\'adhésion du membre', 'description' => '12 mois à compter de la date de validation de l\'adhésion du membre',
'price' => '12.00' 'price' => '12.00',
], ],
[ [
'identifier' => 'two-years', 'identifier' => 'two-years',
'name' => 'Deux ans', 'name' => 'Deux ans',
'description' => '24 mois à compter de la date de validation de l\'adhésion du membre', 'description' => '24 mois à compter de la date de validation de l\'adhésion du membre',
'price' => '24.00' 'price' => '24.00',
] ],
]; ];
foreach ($packages as $package) { foreach ($packages as $package) {
@@ -112,7 +111,7 @@ class DatabaseSeeder extends Seeder
'description' => 'Service d\'hébergement web', 'description' => 'Service d\'hébergement web',
'url' => '#', 'url' => '#',
'icon' => 'database', 'icon' => 'database',
] ],
]; ];
foreach ($services as $service) { foreach ($services as $service) {
@@ -126,6 +125,9 @@ class DatabaseSeeder extends Seeder
]); ]);
} }
// Notification templates
$this->call(NotificationTemplateSeeder::class);
// JaneDoe // JaneDoe
$userTest = User::updateOrCreate([ $userTest = User::updateOrCreate([
'name' => 'JaneDoe', 'name' => 'JaneDoe',

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\NotificationTemplate;
use Illuminate\Database\Seeder;
class NotificationTemplateSeeder extends Seeder
{
public function run(): void
{
NotificationTemplate::updateOrCreate(
['identifier' => 'subscription_expired_phase1'],
[
'name' => 'Adhésion expirée - Phase 1',
'subject' => 'Votre adhésion est expirée',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Votre adhésion est arrivée à expiration le {expiry_date}.</p>'
.'<p>Pour continuer à profiter de nos services, merci de la renouveler.</p>'
.'<p>Merci pour votre confiance.</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'expiry_date' => 'Date de fin d\'adhésion',
],
'is_active' => true,
]
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
return [
'fields' => [
'singular_name' => 'Template Mail',
'plural_name' => 'Templates Mail',
'identifier' => 'Identifiant',
'name' => 'Nom',
'subject' => 'Objet',
'body' => 'Contenu',
'variables' => 'Variables disponibles',
'is_active' => 'Actif',
],
];

View File

@@ -0,0 +1,83 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
const CreateNotificationTemplate = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: CreateNotificationTemplate.url(options),
method: 'get',
})
CreateNotificationTemplate.definition = {
methods: ["get","head"],
url: '/admin/notification-templates/create',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
CreateNotificationTemplate.url = (options?: RouteQueryOptions) => {
return CreateNotificationTemplate.definition.url + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
CreateNotificationTemplate.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: CreateNotificationTemplate.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
CreateNotificationTemplate.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: CreateNotificationTemplate.url(options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
const CreateNotificationTemplateForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: CreateNotificationTemplate.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
CreateNotificationTemplateForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: CreateNotificationTemplate.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
CreateNotificationTemplateForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: CreateNotificationTemplate.url({
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
CreateNotificationTemplate.form = CreateNotificationTemplateForm
export default CreateNotificationTemplate

View File

@@ -0,0 +1,101 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
const EditNotificationTemplate = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: EditNotificationTemplate.url(args, options),
method: 'get',
})
EditNotificationTemplate.definition = {
methods: ["get","head"],
url: '/admin/notification-templates/{record}/edit',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
EditNotificationTemplate.url = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions) => {
if (typeof args === 'string' || typeof args === 'number') {
args = { record: args }
}
if (Array.isArray(args)) {
args = {
record: args[0],
}
}
args = applyUrlDefaults(args)
const parsedArgs = {
record: args.record,
}
return EditNotificationTemplate.definition.url
.replace('{record}', parsedArgs.record.toString())
.replace(/\/+$/, '') + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
EditNotificationTemplate.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: EditNotificationTemplate.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
EditNotificationTemplate.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: EditNotificationTemplate.url(args, options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
const EditNotificationTemplateForm = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: EditNotificationTemplate.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
EditNotificationTemplateForm.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: EditNotificationTemplate.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
EditNotificationTemplateForm.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: EditNotificationTemplate.url(args, {
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
EditNotificationTemplate.form = EditNotificationTemplateForm
export default EditNotificationTemplate

View File

@@ -0,0 +1,83 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
const ListNotificationTemplates = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: ListNotificationTemplates.url(options),
method: 'get',
})
ListNotificationTemplates.definition = {
methods: ["get","head"],
url: '/admin/notification-templates',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
ListNotificationTemplates.url = (options?: RouteQueryOptions) => {
return ListNotificationTemplates.definition.url + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
ListNotificationTemplates.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: ListNotificationTemplates.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
ListNotificationTemplates.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: ListNotificationTemplates.url(options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
const ListNotificationTemplatesForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: ListNotificationTemplates.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
ListNotificationTemplatesForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: ListNotificationTemplates.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
ListNotificationTemplatesForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: ListNotificationTemplates.url({
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
ListNotificationTemplates.form = ListNotificationTemplatesForm
export default ListNotificationTemplates

View File

@@ -0,0 +1,11 @@
import ListNotificationTemplates from './ListNotificationTemplates'
import CreateNotificationTemplate from './CreateNotificationTemplate'
import EditNotificationTemplate from './EditNotificationTemplate'
const Pages = {
ListNotificationTemplates: Object.assign(ListNotificationTemplates, ListNotificationTemplates),
CreateNotificationTemplate: Object.assign(CreateNotificationTemplate, CreateNotificationTemplate),
EditNotificationTemplate: Object.assign(EditNotificationTemplate, EditNotificationTemplate),
}
export default Pages

View File

@@ -0,0 +1,7 @@
import Pages from './Pages'
const NotificationTemplates = {
Pages: Object.assign(Pages, Pages),
}
export default NotificationTemplates

View File

@@ -1,6 +1,7 @@
import MemberGroups from './MemberGroups' import MemberGroups from './MemberGroups'
import Members from './Members' import Members from './Members'
import Memberships from './Memberships' import Memberships from './Memberships'
import NotificationTemplates from './NotificationTemplates'
import Packages from './Packages' import Packages from './Packages'
import Services from './Services' import Services from './Services'
import Users from './Users' import Users from './Users'
@@ -9,6 +10,7 @@ const Resources = {
MemberGroups: Object.assign(MemberGroups, MemberGroups), MemberGroups: Object.assign(MemberGroups, MemberGroups),
Members: Object.assign(Members, Members), Members: Object.assign(Members, Members),
Memberships: Object.assign(Memberships, Memberships), Memberships: Object.assign(Memberships, Memberships),
NotificationTemplates: Object.assign(NotificationTemplates, NotificationTemplates),
Packages: Object.assign(Packages, Packages), Packages: Object.assign(Packages, Packages),
Services: Object.assign(Services, Services), Services: Object.assign(Services, Services),
Users: Object.assign(Users, Users), Users: Object.assign(Users, Users),

View File

@@ -0,0 +1,57 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../wayfinder'
/**
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
* @route '/_boost/browser-logs'
*/
export const browserLogs = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
url: browserLogs.url(options),
method: 'post',
})
browserLogs.definition = {
methods: ["post"],
url: '/_boost/browser-logs',
} satisfies RouteDefinition<["post"]>
/**
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
* @route '/_boost/browser-logs'
*/
browserLogs.url = (options?: RouteQueryOptions) => {
return browserLogs.definition.url + queryParams(options)
}
/**
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
* @route '/_boost/browser-logs'
*/
browserLogs.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
url: browserLogs.url(options),
method: 'post',
})
/**
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
* @route '/_boost/browser-logs'
*/
const browserLogsForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
action: browserLogs.url(options),
method: 'post',
})
/**
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
* @route '/_boost/browser-logs'
*/
browserLogsForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
action: browserLogs.url(options),
method: 'post',
})
browserLogs.form = browserLogsForm
const boost = {
browserLogs: Object.assign(browserLogs, browserLogs),
}
export default boost

View File

@@ -1,6 +1,7 @@
import memberGroups from './member-groups' import memberGroups from './member-groups'
import members from './members' import members from './members'
import memberships from './memberships' import memberships from './memberships'
import notificationTemplates from './notification-templates'
import packages from './packages' import packages from './packages'
import services from './services' import services from './services'
import users from './users' import users from './users'
@@ -10,6 +11,7 @@ const resources = {
memberGroups: Object.assign(memberGroups, memberGroups), memberGroups: Object.assign(memberGroups, memberGroups),
members: Object.assign(members, members), members: Object.assign(members, members),
memberships: Object.assign(memberships, memberships), memberships: Object.assign(memberships, memberships),
notificationTemplates: Object.assign(notificationTemplates, notificationTemplates),
packages: Object.assign(packages, packages), packages: Object.assign(packages, packages),
services: Object.assign(services, services), services: Object.assign(services, services),
users: Object.assign(users, users), users: Object.assign(users, users),

View File

@@ -0,0 +1,269 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../wayfinder'
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: index.url(options),
method: 'get',
})
index.definition = {
methods: ["get","head"],
url: '/admin/notification-templates',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
index.url = (options?: RouteQueryOptions) => {
return index.definition.url + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
index.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: index.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
index.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: index.url(options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
const indexForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: index.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
indexForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: index.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
* @route '/admin/notification-templates'
*/
indexForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: index.url({
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
index.form = indexForm
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: create.url(options),
method: 'get',
})
create.definition = {
methods: ["get","head"],
url: '/admin/notification-templates/create',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
create.url = (options?: RouteQueryOptions) => {
return create.definition.url + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: create.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: create.url(options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: create.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: create.url(options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
* @route '/admin/notification-templates/create'
*/
createForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: create.url({
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
create.form = createForm
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
export const edit = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: edit.url(args, options),
method: 'get',
})
edit.definition = {
methods: ["get","head"],
url: '/admin/notification-templates/{record}/edit',
} satisfies RouteDefinition<["get","head"]>
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
edit.url = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions) => {
if (typeof args === 'string' || typeof args === 'number') {
args = { record: args }
}
if (Array.isArray(args)) {
args = {
record: args[0],
}
}
args = applyUrlDefaults(args)
const parsedArgs = {
record: args.record,
}
return edit.definition.url
.replace('{record}', parsedArgs.record.toString())
.replace(/\/+$/, '') + queryParams(options)
}
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
edit.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: edit.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
edit.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: edit.url(args, options),
method: 'head',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
const editForm = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: edit.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
editForm.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: edit.url(args, options),
method: 'get',
})
/**
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
* @route '/admin/notification-templates/{record}/edit'
*/
editForm.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: edit.url(args, {
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
edit.form = editForm
const notificationTemplates = {
index: Object.assign(index, index),
create: Object.assign(create, create),
edit: Object.assign(edit, edit),
}
export default notificationTemplates

View File

@@ -0,0 +1,3 @@
<x-mail::message>
{!! $body !!}
</x-mail::message>

View File

@@ -0,0 +1,77 @@
<?php
namespace Tests\Feature;
use App\Models\NotificationTemplate;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class NotificationTemplateTest extends TestCase
{
use RefreshDatabase;
public function test_find_by_identifier_returns_active_template(): void
{
$template = NotificationTemplate::factory()->create([
'identifier' => 'test_template',
'is_active' => true,
]);
$found = NotificationTemplate::findByIdentifier('test_template');
$this->assertNotNull($found);
$this->assertEquals($template->id, $found->id);
}
public function test_find_by_identifier_returns_null_for_inactive_template(): void
{
NotificationTemplate::factory()->inactive()->create([
'identifier' => 'inactive_template',
]);
$found = NotificationTemplate::findByIdentifier('inactive_template');
$this->assertNull($found);
}
public function test_render_subject_replaces_placeholders(): void
{
$template = NotificationTemplate::factory()->create([
'subject' => 'Bonjour {member_name}, votre adhésion expire le {expiry_date}',
]);
$result = $template->renderSubject([
'member_name' => 'Jean Dupont',
'expiry_date' => '2026-01-31',
]);
$this->assertEquals('Bonjour Jean Dupont, votre adhésion expire le 2026-01-31', $result);
}
public function test_render_body_replaces_placeholders(): void
{
$template = NotificationTemplate::factory()->create([
'body' => '<p>Bonjour {member_name}</p><p>Expiration : {expiry_date}</p>',
]);
$result = $template->renderBody([
'member_name' => 'Marie Martin',
'expiry_date' => '2026-06-15',
]);
$this->assertEquals('<p>Bonjour Marie Martin</p><p>Expiration : 2026-06-15</p>', $result);
}
public function test_missing_placeholder_is_left_intact(): void
{
$template = NotificationTemplate::factory()->create([
'subject' => 'Bonjour {member_name}, date : {expiry_date}',
]);
$result = $template->renderSubject([
'member_name' => 'Jean Dupont',
]);
$this->assertEquals('Bonjour Jean Dupont, date : {expiry_date}', $result);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Jobs\SendSubscriptionExpiredPhase1Notifications;
use App\Models\Member;
use App\Models\Membership;
use App\Models\NotificationTemplate;
use App\Models\Package;
use App\Notifications\SubscriptionExpiredPhase1;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class SendSubscriptionExpiredPhase1NotificationsTest extends TestCase
{
use RefreshDatabase;
private function createMemberWithExpiredMembership(): Member
{
$package = Package::create([
'identifier' => 'test-package',
'name' => 'Test Package',
'is_active' => true,
]);
$member = Member::create([
'email' => fake()->unique()->safeEmail(),
'firstname' => fake()->firstName(),
'lastname' => fake()->lastName(),
'status' => 'valid',
'nature' => 'physical',
]);
Membership::create([
'member_id' => $member->id,
'package_id' => $package->id,
'status' => 'expired',
'end_date' => '2025-12-31',
'amount' => 12.00,
'payment_status' => 'paid',
]);
return $member;
}
public function test_job_sends_notifications_to_expired_members(): void
{
Notification::fake();
NotificationTemplate::factory()->create([
'identifier' => 'subscription_expired_phase1',
'is_active' => true,
]);
$expiredMember = $this->createMemberWithExpiredMembership();
(new SendSubscriptionExpiredPhase1Notifications)->handle();
Notification::assertSentTo($expiredMember, SubscriptionExpiredPhase1::class);
}
public function test_job_does_nothing_when_template_is_inactive(): void
{
Notification::fake();
NotificationTemplate::factory()->inactive()->create([
'identifier' => 'subscription_expired_phase1',
]);
$this->createMemberWithExpiredMembership();
(new SendSubscriptionExpiredPhase1Notifications)->handle();
Notification::assertNothingSent();
}
public function test_job_does_nothing_when_template_does_not_exist(): void
{
Notification::fake();
$this->createMemberWithExpiredMembership();
(new SendSubscriptionExpiredPhase1Notifications)->handle();
Notification::assertNothingSent();
}
}