diff --git a/- b/- new file mode 100644 index 0000000..e69de29 diff --git a/app/Console/Commands/MemberRenewalProcessCommand.php b/app/Console/Commands/MemberRenewalProcessCommand.php new file mode 100644 index 0000000..3a7afb9 --- /dev/null +++ b/app/Console/Commands/MemberRenewalProcessCommand.php @@ -0,0 +1,150 @@ +option('dry-run'); + $emailFilter = $this->argument('email'); + $phaseOption = $this->option('phase'); + $year = $this->option('year') ?? now()->addYear()->year; + + $this->info( + $dryRun + ? 'DRY-RUN activé – aucune modification ne sera effectuée' + : 'Mode réel activé' + ); + + $phase = $phaseOption === 'auto' ? $this->determinePhase() : $phaseOption; + + if ($phase === null) { + $this->warn('Mode auto : aucune phase détectée pour la date actuelle'); + + return CommandAlias::SUCCESS; + } + + $this->info("Phase détectée : {$phase}"); + $this->info("Année de renouvellement : {$year}"); + + if ($emailFilter) { + $this->warn("Mode utilisateur unique : {$emailFilter}"); + } + + $members = $this->getEligibleMembers($emailFilter); + + if ($members->isEmpty()) { + $this->info('Aucun adhérent éligible'); + + return CommandAlias::SUCCESS; + } + + $this->info("{$members->count()} adhérent(s) à traiter"); + + foreach ($members as $member) { + try { + $this->processMember($member, $phase, $year, $dryRun); + } catch (\Throwable $e) { + Log::error('Erreur traitement adhérent relance', [ + 'member_id' => $member->id, + 'phase' => $phase, + 'error' => $e->getMessage(), + ]); + $this->error("Erreur pour {$member->email} : {$e->getMessage()}"); + } + } + + $this->info( + $dryRun + ? 'DRY-RUN terminé – aucune action effectuée' + : 'Traitement terminé' + ); + + return CommandAlias::SUCCESS; + } + + protected function determinePhase(): ?string + { + $today = now(); + + if ($today->month === 12 && $today->day >= 1 && $today->day <= 10) { + return 'reminder_1'; + } + + if ($today->month === 12 && $today->day >= 15 && $today->day <= 20) { + return 'reminder_2'; + } + + if ($today->month === 1 && $today->day >= 4 && $today->day <= 6) { + return 'reminder_3'; + } + + if ($today->month === 1 && $today->day >= 25 && $today->day <= 31) { + return 'deactivate'; + } + + return null; + } + + protected function getEligibleMembers(?string $emailFilter): \Illuminate\Support\Collection + { + $query = Member::query() + ->where('status', 'valid') + ->whereHas('memberships', function ($q) { + $q->where('status', 'active'); + }); + + if ($emailFilter) { + $query->where('email', $emailFilter) + ->orWhere('retzien_email', $emailFilter); + } + + return $query->get(); + } + + protected function processMember(Member $member, string $phase, int $year, bool $dryRun): void + { + $this->line("• {$member->id} - {$member->email}"); + + if ($dryRun) { + $this->info("[DRY-RUN] Phase {$phase}"); + + return; + } + + $sent = match ($phase) { + 'reminder_1' => $this->renewalService->sendReminder1($member, $year), + 'reminder_2' => $this->renewalService->sendReminder2($member, $year), + 'reminder_3' => $this->renewalService->sendReminder3($member, $year), + 'deactivate' => $this->renewalService->deactivateMember($member, $year), + default => false, + }; + + if ($sent) { + $this->info(" ✓ Relance envoyée"); + } else { + $this->warn(" → Aucune relance envoyée (Membre non concerné / mail de relance déjà envoyé / adhésion active pour {$year})"); + } + } +} diff --git a/app/Console/Commands/SyncServicesMembers.php b/app/Console/Commands/SyncServicesMembers.php index 2be78e6..ccaef32 100644 --- a/app/Console/Commands/SyncServicesMembers.php +++ b/app/Console/Commands/SyncServicesMembers.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use App\Models\Member; use App\Models\Service; use Illuminate\Console\Command; + use function Laravel\Prompts\progress; class SyncServicesMembers extends Command @@ -31,7 +32,7 @@ class SyncServicesMembers extends Command // Tous les membres ayant une adhésion en court ont les services activés $this->info('Syncing services members...'); - $members = Member::whereIn('status', ['valid', 'pending'] )->get(); + $members = Member::whereIn('status', ['valid', 'pending'])->get(); $progressBar = progress(label: 'Syncing services members', steps: $members->count()); diff --git a/app/Enums/IspconfigType.php b/app/Enums/IspconfigType.php index 92f2593..06cef8c 100644 --- a/app/Enums/IspconfigType.php +++ b/app/Enums/IspconfigType.php @@ -5,14 +5,14 @@ namespace App\Enums; enum IspconfigType: string { case MAIL = 'mail'; - case WEB = 'web'; + case WEB = 'web'; case OTHER = 'other'; public function label(): string { return match ($this) { - self::MAIL => 'Email', - self::WEB => 'Hébergement', + self::MAIL => 'Email', + self::WEB => 'Hébergement', self::OTHER => 'Autre', }; } diff --git a/app/Filament/Actions/SendRenewalReminderAction.php b/app/Filament/Actions/SendRenewalReminderAction.php new file mode 100644 index 0000000..77b8404 --- /dev/null +++ b/app/Filament/Actions/SendRenewalReminderAction.php @@ -0,0 +1,62 @@ +label('Envoyer une relance') + ->icon('heroicon-o-envelope') + ->color('primary') + ->disabled(function () { + $today = now(); + + return ! ($today->month === 12 || ($today->month === 1 && $today->day <= 31)); + }) + ->requiresConfirmation() + ->modalHeading('Envoyer une relance de renouvellement') + ->modalDescription(fn (Membership $record) => "Envoyer une relance à {$record->member->full_name} ({$record->member->email}) ?") + ->form([ + Select::make('reminder_type') + ->label('Type de relance') + ->options([ + 'reminder_1' => '1ère relance (début décembre)', + 'reminder_2' => '2ème relance (mi-décembre)', + 'reminder_3' => '3ème relance (début janvier)', + ]) + ->required() + ->default('reminder_1'), + + TextInput::make('year') + ->label('Année de renouvellement') + ->default(now()->addYear()->year) + ->numeric() + ->required(), + ]) + ->action(function (Membership $record, array $data) { + $service = app(MemberRenewalService::class); + $member = $record->member; + $year = (int) $data['year']; + + match ($data['reminder_type']) { + 'reminder_1' => $service->sendReminder1($member, $year), + 'reminder_2' => $service->sendReminder2($member, $year), + 'reminder_3' => $service->sendReminder3($member, $year), + }; + + Notification::make() + ->title('Relance envoyée') + ->success() + ->send(); + }); + } +} diff --git a/app/Filament/Resources/MemberGroups/MemberGroupResource.php b/app/Filament/Resources/MemberGroups/MemberGroupResource.php index e33280c..31a4848 100644 --- a/app/Filament/Resources/MemberGroups/MemberGroupResource.php +++ b/app/Filament/Resources/MemberGroups/MemberGroupResource.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\MemberGroups; use App\Filament\Resources\MemberGroups\Pages\CreateMemberGroup; use App\Filament\Resources\MemberGroups\Pages\EditMemberGroup; use App\Filament\Resources\MemberGroups\Pages\ListMemberGroups; -use App\Filament\Resources\MemberGroups\RelationManagers\MemberRelationManager; use App\Filament\Resources\MemberGroups\RelationManagers\MembersRelationManager; use App\Filament\Resources\MemberGroups\Schemas\MemberGroupForm; use App\Filament\Resources\MemberGroups\Tables\MemberGroupsTable; diff --git a/app/Filament/Resources/MemberGroups/Schemas/MemberGroupForm.php b/app/Filament/Resources/MemberGroups/Schemas/MemberGroupForm.php index dc2098e..e7fa3ba 100644 --- a/app/Filament/Resources/MemberGroups/Schemas/MemberGroupForm.php +++ b/app/Filament/Resources/MemberGroups/Schemas/MemberGroupForm.php @@ -17,14 +17,13 @@ class MemberGroupForm ->components([ Section::make(fn (?MemberGroup $record) => $record?->name ?? MemberGroup::getAttributeLabel('name')) ->headerActions([ - Action::make('view-members') + Action::make('view-members') ->icon('heroicon-o-users') ->label(MemberGroup::getAttributeLabel('view_members')) ->action(function (MemberGroup $record) { - return redirect()->route('filament.admin.resources.members.index', [ 'filters[group][name][values][0]' => $record->id]); + return redirect()->route('filament.admin.resources.members.index', ['filters[group][name][values][0]' => $record->id]); }) - ->visible(fn(?MemberGroup $record) => $record !== null) - + ->visible(fn (?MemberGroup $record) => $record !== null), ]) ->schema([ diff --git a/app/Filament/Resources/Members/Tables/MembersTable.php b/app/Filament/Resources/Members/Tables/MembersTable.php index 1f5c3c2..c42d2ac 100644 --- a/app/Filament/Resources/Members/Tables/MembersTable.php +++ b/app/Filament/Resources/Members/Tables/MembersTable.php @@ -6,11 +6,9 @@ use App\Models\Member; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; -use Filament\Tables\Filters\SelectFilter; -use Illuminate\Database\Eloquent\Builder; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Filters\Filter; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class MembersTable @@ -83,7 +81,7 @@ class MembersTable ->toolbarActions([ BulkActionGroup::make([ DeleteBulkAction::make(), - ]) + ]), ]); } } diff --git a/app/Filament/Resources/Members/Widgets/MemberCount.php b/app/Filament/Resources/Members/Widgets/MemberCount.php index ed44924..9a4a5ad 100644 --- a/app/Filament/Resources/Members/Widgets/MemberCount.php +++ b/app/Filament/Resources/Members/Widgets/MemberCount.php @@ -16,7 +16,7 @@ class MemberCount extends StatsOverviewWidget ->description(Member::getAttributeLabel('widgets.stats.description')) ->descriptionIcon('heroicon-o-user-group', IconPosition::Before) ->chart([7, 2, 10, 3, 15, 4, 17]) - ->color('primary') + ->color('primary'), ]; } } diff --git a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php index 0c22cf6..5bd789a 100644 --- a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php +++ b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Memberships\Schemas; use App\Enums\IspconfigType; +use App\Filament\Actions\SendRenewalReminderAction; use App\Filament\Actions\ServiceToggleAction; use App\Models\ListmonkMember; use App\Models\Membership; @@ -261,6 +262,12 @@ class MembershipForm ->label(Membership::getAttributeLabel('end_date')), ]) ->extraAttributes(['class' => 'sticky top-4 h-fit']), + + Section::make('Relances') + ->schema([ + SendRenewalReminderAction::make(), + ]) + ->extraAttributes(['class' => 'sticky top-4 h-fit']), ]) ->columnSpan(1), ]) diff --git a/app/Filament/Resources/Packages/Schemas/PackageForm.php b/app/Filament/Resources/Packages/Schemas/PackageForm.php index 656314e..034e5dd 100644 --- a/app/Filament/Resources/Packages/Schemas/PackageForm.php +++ b/app/Filament/Resources/Packages/Schemas/PackageForm.php @@ -23,9 +23,9 @@ class PackageForm ]) ->schema([ TextInput::make('name') - ->label(Package::getAttributeLabel('name')) - ->required() - ->default(null), + ->label(Package::getAttributeLabel('name')) + ->required() + ->default(null), TextInput::make('identifier') ->label(Package::getAttributeLabel('identifier')) ->required() @@ -34,7 +34,7 @@ class PackageForm Textarea::make('description') ->label(Package::getAttributeLabel('description')) ->default(null), - ]) + ]), ]); } diff --git a/app/Filament/Resources/Packages/Tables/PackagesTable.php b/app/Filament/Resources/Packages/Tables/PackagesTable.php index 60cf421..97ea55c 100644 --- a/app/Filament/Resources/Packages/Tables/PackagesTable.php +++ b/app/Filament/Resources/Packages/Tables/PackagesTable.php @@ -41,7 +41,7 @@ class PackagesTable ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + // ]) ->recordActions([ EditAction::make(), diff --git a/app/Filament/Resources/Services/Schemas/ServiceForm.php b/app/Filament/Resources/Services/Schemas/ServiceForm.php index 7b44380..871958c 100644 --- a/app/Filament/Resources/Services/Schemas/ServiceForm.php +++ b/app/Filament/Resources/Services/Schemas/ServiceForm.php @@ -31,7 +31,7 @@ class ServiceForm TextInput::make('icon') ->label(Service::getAttributeLabel('icon')) ->default(null), - ]) + ]), ]); } } diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php index 5f68f75..c4326b0 100644 --- a/app/Filament/Resources/Users/UserResource.php +++ b/app/Filament/Resources/Users/UserResource.php @@ -19,6 +19,7 @@ class UserResource extends Resource protected static ?string $model = User::class; protected static string|null|\UnitEnum $navigationGroup = 'Paramètres'; + protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack; public static function form(Schema $schema): Schema @@ -51,6 +52,7 @@ class UserResource extends Resource { return User::getAttributeLabel('user'); } + public static function getPluralModelLabel(): string { return User::getAttributeLabel('users'); diff --git a/app/Http/Controllers/Forms/MembershipFormController.php b/app/Http/Controllers/Forms/MembershipFormController.php index 98610af..3832204 100644 --- a/app/Http/Controllers/Forms/MembershipFormController.php +++ b/app/Http/Controllers/Forms/MembershipFormController.php @@ -25,7 +25,7 @@ class MembershipFormController extends Controller ->where('is_active', true) ->select('id', 'identifier', 'name', 'price', 'description') ->get() - ->map(function (Package $p) use ($remainingMonths, $currentYear) { + ->map(function (Package $p) use ($remainingMonths) { $months = match ($p->identifier) { 'custom' => $remainingMonths, 'one-year' => $remainingMonths + 12, diff --git a/app/Models/Contact.php b/app/Models/Contact.php index eef634b..d330cf6 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -45,6 +45,7 @@ class Contact extends Model { return __("contacts.fields.$attribute"); } + public function getFullNameAttribute(): string { return "{$this->firstname} {$this->lastname}"; diff --git a/app/Models/ListmonkMember.php b/app/Models/ListmonkMember.php index e37cf4e..d953866 100644 --- a/app/Models/ListmonkMember.php +++ b/app/Models/ListmonkMember.php @@ -13,7 +13,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Member $member - * + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember query() + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereData($value) + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereListmonkUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereMemberId($value) + * @method static \Illuminate\Database\Eloquent\Builder|ListmonkMember whereUpdatedAt($value) * @mixin \Eloquent */ class ListmonkMember extends Model diff --git a/app/Models/Member.php b/app/Models/Member.php index d8e45ad..d5b3ad6 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -19,10 +19,10 @@ use Illuminate\Notifications\Notifiable; * @property string $nature * @property int|null $type_id * @property int|null $group_id - * @property string|null $lastname * @property string|null $firstname + * @property string|null $lastname * @property string $email - * @property string $retzien_email + * @property string|null $retzien_email * @property string|null $company * @property string|null $date_of_birth * @property string|null $address @@ -40,17 +40,23 @@ use Illuminate\Notifications\Notifiable; * @property-read \App\Models\MemberGroup|null $group * @property-read \Illuminate\Database\Eloquent\Collection $ispconfigs * @property-read int|null $ispconfigs_count + * @property-read \App\Models\Membership|null $lastActiveMembership + * @property-read \Illuminate\Database\Eloquent\Collection $listmonkMembers + * @property-read int|null $listmonk_members_count * @property-read \Illuminate\Database\Eloquent\Collection $memberships * @property-read int|null $memberships_count * @property-read \Illuminate\Database\Eloquent\Collection $nextcloudAccounts * @property-read int|null $nextcloud_accounts_count * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications * @property-read int|null $notifications_count + * @property-read \Illuminate\Database\Eloquent\Collection $renewalReminders + * @property-read int|null $renewal_reminders_count + * @property-read \App\Models\MemberType|null $type * @property-read \App\Models\User|null $user - * * @method static \Database\Factories\MemberFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|Member newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Member newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Member onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Member query() * @method static \Illuminate\Database\Eloquent\Builder|Member whereAddress($value) * @method static \Illuminate\Database\Eloquent\Builder|Member whereCity($value) @@ -77,7 +83,8 @@ use Illuminate\Notifications\Notifiable; * @method static \Illuminate\Database\Eloquent\Builder|Member whereUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|Member whereWebsiteUrl($value) * @method static \Illuminate\Database\Eloquent\Builder|Member whereZipcode($value) - * + * @method static \Illuminate\Database\Eloquent\Builder|Member withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|Member withoutTrashed() * @mixin \Eloquent */ class Member extends Model @@ -163,6 +170,11 @@ class Member extends Model return $this->hasMany(ListmonkMember::class, 'member_id'); } + public function renewalReminders(): HasMany + { + return $this->hasMany(MemberRenewalReminder::class); + } + public function lastActiveMembership(): HasOne { return $this->hasOne(Membership::class)->where('status', 'active')->latest(); diff --git a/app/Models/MemberGroup.php b/app/Models/MemberGroup.php index 607f215..4473a52 100644 --- a/app/Models/MemberGroup.php +++ b/app/Models/MemberGroup.php @@ -32,12 +32,12 @@ class MemberGroup extends Model protected $fillable = [ 'name', 'description', - 'identifier' + 'identifier', ]; public static function getAttributeLabel(string $attribute): string { - return __('member_groups.fields.' . $attribute); + return __('member_groups.fields.'.$attribute); } public function members(): HasMany diff --git a/app/Models/MemberRenewalReminder.php b/app/Models/MemberRenewalReminder.php new file mode 100644 index 0000000..ddbf3ca --- /dev/null +++ b/app/Models/MemberRenewalReminder.php @@ -0,0 +1,107 @@ +|MemberRenewalReminder newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder query() + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereDeactivatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereDeactivationSent($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereMemberId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder1Sent($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder1SentAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder2Sent($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder2SentAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder3Sent($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereReminder3SentAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereRenewalYear($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberRenewalReminder whereUpdatedAt($value) + * @mixin \Eloquent + */ +class MemberRenewalReminder extends Model +{ + protected $fillable = [ + 'member_id', + 'renewal_year', + 'reminder_1_sent', + 'reminder_2_sent', + 'reminder_3_sent', + 'deactivation_sent', + 'reminder_1_sent_at', + 'reminder_2_sent_at', + 'reminder_3_sent_at', + 'deactivated_at', + ]; + + protected function casts(): array + { + return [ + 'reminder_1_sent' => 'boolean', + 'reminder_2_sent' => 'boolean', + 'reminder_3_sent' => 'boolean', + 'deactivation_sent' => 'boolean', + 'reminder_1_sent_at' => 'datetime', + 'reminder_2_sent_at' => 'datetime', + 'reminder_3_sent_at' => 'datetime', + 'deactivated_at' => 'datetime', + 'renewal_year' => 'integer', + ]; + } + + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + public function markAsReminder1Sent(): void + { + $this->update([ + 'reminder_1_sent' => true, + 'reminder_1_sent_at' => now(), + ]); + } + + public function markAsReminder2Sent(): void + { + $this->update([ + 'reminder_2_sent' => true, + 'reminder_2_sent_at' => now(), + ]); + } + + public function markAsReminder3Sent(): void + { + $this->update([ + 'reminder_3_sent' => true, + 'reminder_3_sent_at' => now(), + ]); + } + + public function markAsDeactivated(): void + { + $this->update([ + 'deactivation_sent' => true, + 'deactivated_at' => now(), + ]); + } +} diff --git a/app/Models/MemberType.php b/app/Models/MemberType.php index e9d1275..f187234 100644 --- a/app/Models/MemberType.php +++ b/app/Models/MemberType.php @@ -5,6 +5,29 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property int $id + * @property string $identifier + * @property string $name + * @property string|null $description + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $deleted_at + * @method static \Illuminate\Database\Eloquent\Builder|MemberType newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MemberType newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MemberType onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|MemberType query() + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereDeletedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereDescription($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereIdentifier($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|MemberType withoutTrashed() + * @mixin \Eloquent + */ class MemberType extends Model { use SoftDeletes; @@ -14,4 +37,4 @@ class MemberType extends Model 'name', 'description', ]; -} \ No newline at end of file +} diff --git a/app/Models/Membership.php b/app/Models/Membership.php index b47886d..4c83277 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -16,7 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; * @property string $status * @property string|null $validation_date * @property string|null $payment_method - * @property string $amount + * @property numeric $amount * @property string $payment_status * @property string|null $note_public * @property string|null $note_private @@ -30,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; * @property-read \App\Models\Package $package * @property-read \Illuminate\Database\Eloquent\Collection $services * @property-read int|null $services_count - * * @method static \Illuminate\Database\Eloquent\Builder|Membership newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Membership newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Membership query() @@ -52,7 +51,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; * @method static \Illuminate\Database\Eloquent\Builder|Membership whereStatus($value) * @method static \Illuminate\Database\Eloquent\Builder|Membership whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Membership whereValidationDate($value) - * * @mixin \Eloquent */ class Membership extends Model diff --git a/app/Models/NextCloudMember.php b/app/Models/NextCloudMember.php index 5795f86..cbba250 100644 --- a/app/Models/NextCloudMember.php +++ b/app/Models/NextCloudMember.php @@ -22,7 +22,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @method static \Illuminate\Database\Eloquent\Builder|NextCloudMember whereMemberId($value) * @method static \Illuminate\Database\Eloquent\Builder|NextCloudMember whereNextcloudUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|NextCloudMember whereUpdatedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|NextCloudMember whereUsername($value) * @mixin \Eloquent */ class NextCloudMember extends Model diff --git a/app/Models/NotificationTemplate.php b/app/Models/NotificationTemplate.php index b45c980..1f7d42e 100644 --- a/app/Models/NotificationTemplate.php +++ b/app/Models/NotificationTemplate.php @@ -6,6 +6,36 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property int $id + * @property string $identifier + * @property string $name + * @property string $subject + * @property string $body + * @property array|null $variables + * @property bool $is_active + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $deleted_at + * @method static \Database\Factories\NotificationTemplateFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate query() + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereBody($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereDeletedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereIdentifier($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereIsActive($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereSubject($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate whereVariables($value) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|NotificationTemplate withoutTrashed() + * @mixin \Eloquent + */ class NotificationTemplate extends Model { use HasFactory, SoftDeletes; diff --git a/app/Models/Package.php b/app/Models/Package.php index 8e8ec93..60ebbe1 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -3,14 +3,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id * @property string $identifier * @property string $name * @property string|null $description - * @property string $price + * @property numeric $price * @property int $is_active * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at @@ -40,7 +39,6 @@ class Package extends Model public static function getAttributeLabel(string $attribute): string { - return __('packages.fields.' . $attribute); + return __('packages.fields.'.$attribute); } - } diff --git a/app/Models/Service.php b/app/Models/Service.php index 69af44b..b26243e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -43,10 +43,10 @@ class Service extends Model public static function getAttributeLabel(string $attribute): string { - return __('services.fields.' . $attribute); + return __('services.fields.'.$attribute); } - public function memberships() : BelongsToMany + public function memberships(): BelongsToMany { return $this->belongsToMany(Membership::class, 'services_memberships', 'service_id', 'membership_id'); } diff --git a/app/Models/User.php b/app/Models/User.php index a790223..51ec8ab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,7 +30,6 @@ use Spatie\Permission\Traits\HasRoles; * @property-read int|null $permissions_count * @property-read \Illuminate\Database\Eloquent\Collection $roles * @property-read int|null $roles_count - * * @method static \Database\Factories\UserFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() @@ -47,7 +46,6 @@ use Spatie\Permission\Traits\HasRoles; * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User withoutPermission($permissions) * @method static \Illuminate\Database\Eloquent\Builder|User withoutRole($roles, $guard = null) - * * @mixin \Eloquent */ class User extends Authenticatable implements FilamentUser diff --git a/app/Notifications/MemberAccountDeactivatedNotification.php b/app/Notifications/MemberAccountDeactivatedNotification.php new file mode 100644 index 0000000..463e506 --- /dev/null +++ b/app/Notifications/MemberAccountDeactivatedNotification.php @@ -0,0 +1,51 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $template = NotificationTemplate::findByIdentifier('member_account_deactivated'); + + $vars = [ + 'member_name' => $this->member->full_name, + 'app_name' => config('app.front_name'), + ]; + + return (new MailMessage) + ->subject($template->renderSubject($vars)) + ->view('notifications.mail-template', [ + 'body' => $template->renderBody($vars), + ]); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Notifications/MemberRenewalReminder2Notification.php b/app/Notifications/MemberRenewalReminder2Notification.php new file mode 100644 index 0000000..e7373ef --- /dev/null +++ b/app/Notifications/MemberRenewalReminder2Notification.php @@ -0,0 +1,51 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $template = NotificationTemplate::findByIdentifier('member_renewal_reminder_2'); + + $vars = [ + 'member_name' => $this->member->full_name, + 'app_name' => config('app.front_name'), + ]; + + return (new MailMessage) + ->subject($template->renderSubject($vars)) + ->view('notifications.mail-template', [ + 'body' => $template->renderBody($vars), + ]); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Notifications/MemberRenewalReminder3Notification.php b/app/Notifications/MemberRenewalReminder3Notification.php new file mode 100644 index 0000000..2e76f76 --- /dev/null +++ b/app/Notifications/MemberRenewalReminder3Notification.php @@ -0,0 +1,51 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $template = NotificationTemplate::findByIdentifier('member_renewal_reminder_3'); + + $vars = [ + 'member_name' => $this->member->full_name, + 'app_name' => config('app.front_name'), + ]; + + return (new MailMessage) + ->subject($template->renderSubject($vars)) + ->view('notifications.mail-template', [ + 'body' => $template->renderBody($vars), + ]); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Notifications/MemberRenewalReminderNotification.php b/app/Notifications/MemberRenewalReminderNotification.php index 11c3427..0c4f453 100644 --- a/app/Notifications/MemberRenewalReminderNotification.php +++ b/app/Notifications/MemberRenewalReminderNotification.php @@ -48,4 +48,4 @@ class MemberRenewalReminderNotification extends Notification implements ShouldQu { return []; } -} \ No newline at end of file +} diff --git a/app/Policies/MemberGroupPolicy.php b/app/Policies/MemberGroupPolicy.php index 8f97e1a..85c6d15 100644 --- a/app/Policies/MemberGroupPolicy.php +++ b/app/Policies/MemberGroupPolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use App\Models\MemberGroup; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class MemberGroupPolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:MemberGroup'); @@ -66,5 +66,4 @@ class MemberGroupPolicy { return $authUser->can('Reorder:MemberGroup'); } - -} \ No newline at end of file +} diff --git a/app/Policies/MemberPolicy.php b/app/Policies/MemberPolicy.php index 02c15da..851a681 100644 --- a/app/Policies/MemberPolicy.php +++ b/app/Policies/MemberPolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use App\Models\Member; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class MemberPolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:Member'); @@ -66,5 +66,4 @@ class MemberPolicy { return $authUser->can('Reorder:Member'); } - -} \ No newline at end of file +} diff --git a/app/Policies/MembershipPolicy.php b/app/Policies/MembershipPolicy.php index dc6e595..f905e97 100644 --- a/app/Policies/MembershipPolicy.php +++ b/app/Policies/MembershipPolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use App\Models\Membership; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class MembershipPolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:Membership'); @@ -66,5 +66,4 @@ class MembershipPolicy { return $authUser->can('Reorder:Membership'); } - -} \ No newline at end of file +} diff --git a/app/Policies/PackagePolicy.php b/app/Policies/PackagePolicy.php index 0f2b7b6..4cbff15 100644 --- a/app/Policies/PackagePolicy.php +++ b/app/Policies/PackagePolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use App\Models\Package; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class PackagePolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:Package'); @@ -66,5 +66,4 @@ class PackagePolicy { return $authUser->can('Reorder:Package'); } - -} \ No newline at end of file +} diff --git a/app/Policies/RolePolicy.php b/app/Policies/RolePolicy.php index a2a3263..1654cec 100644 --- a/app/Policies/RolePolicy.php +++ b/app/Policies/RolePolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; +use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Foundation\Auth\User as AuthUser; use Spatie\Permission\Models\Role; -use Illuminate\Auth\Access\HandlesAuthorization; class RolePolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:Role'); @@ -66,5 +66,4 @@ class RolePolicy { return $authUser->can('Reorder:Role'); } - -} \ No newline at end of file +} diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index b1d20c1..62db97d 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use App\Models\Service; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class ServicePolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:Service'); @@ -66,5 +66,4 @@ class ServicePolicy { return $authUser->can('Reorder:Service'); } - -} \ No newline at end of file +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 10cdce3..86ba02f 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -2,13 +2,13 @@ namespace App\Policies; -use Illuminate\Foundation\Auth\User as AuthUser; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Foundation\Auth\User as AuthUser; class UserPolicy { use HandlesAuthorization; - + public function viewAny(AuthUser $authUser): bool { return $authUser->can('ViewAny:User'); @@ -63,5 +63,4 @@ class UserPolicy { return $authUser->can('Reorder:User'); } - -} \ No newline at end of file +} diff --git a/app/Services/Dolibarr/DolibarrService.php b/app/Services/Dolibarr/DolibarrService.php index 468d755..0d28330 100644 --- a/app/Services/Dolibarr/DolibarrService.php +++ b/app/Services/Dolibarr/DolibarrService.php @@ -9,9 +9,13 @@ use Illuminate\Support\Facades\Http; class DolibarrService { protected string $baseUrl; + protected string $htaccessUrl; + protected string $username; + protected string $password; + protected string $apiKey; public function __construct() @@ -20,7 +24,7 @@ class DolibarrService $this->htaccessUrl = config('services.dolibarr.htaccess_url'); $this->username = config('services.dolibarr.username'); $this->password = config('services.dolibarr.password'); - $this->apiKey = config('services.dolibarr.api_key'); + $this->apiKey = config('services.dolibarr.api_key'); } /** @@ -30,21 +34,22 @@ class DolibarrService { return Http::withBasicAuth($this->username, $this->password) ->withHeaders([ - 'Accept' => 'application/json', - 'DOLAPIKEY' => $this->apiKey, + 'Accept' => 'application/json', + 'DOLAPIKEY' => $this->apiKey, ]); } /** * Get all members + * * @throws ConnectionException */ public function getAllMembers(int $limit = 400, string $sortField = 't.rowid', string $sortOrder = 'ASC'): array { - $response = $this->client()->get($this->baseUrl . '/members', [ + $response = $this->client()->get($this->baseUrl.'/members', [ 'sortfield' => $sortField, 'sortorder' => $sortOrder, - 'limit' => $limit, + 'limit' => $limit, ]); return $response->json(); @@ -52,11 +57,12 @@ class DolibarrService /** * Get member subscriptions + * * @throws ConnectionException */ public function getMemberSubscriptions(int|string $id): array { - $response = $this->client()->get($this->baseUrl . '/members/'. $id . '/subscriptions'); + $response = $this->client()->get($this->baseUrl.'/members/'.$id.'/subscriptions'); return $response->json(); } @@ -64,19 +70,18 @@ class DolibarrService /** * Update a member with custom data * - * @param int|string $id The Dolibarr member ID (rowid) - * @param array $data Array of attributes to update (e.g. ['email' => 'new@email.com', 'array_options' => ['options_custom' => 'val']]) + * @param int|string $id The Dolibarr member ID (rowid) + * @param array $data Array of attributes to update (e.g. ['email' => 'new@email.com', 'array_options' => ['options_custom' => 'val']]) + * * @throws ConnectionException */ public function updateMember(int|string $id, array $data): bool { $response = $this->client()->put( - $this->baseUrl . '/members/' . $id, + $this->baseUrl.'/members/'.$id, $data ); return $response->successful(); } - - } diff --git a/app/Services/ISPConfig/ISPConfigMailService.php b/app/Services/ISPConfig/ISPConfigMailService.php index b22d16e..b3a5b8c 100644 --- a/app/Services/ISPConfig/ISPConfigMailService.php +++ b/app/Services/ISPConfig/ISPConfigMailService.php @@ -18,9 +18,9 @@ class ISPConfigMailService extends ISPConfigService public function getAllMailDomains(): array { return Cache::remember( - "ispconfig.mail.domains.all", + 'ispconfig.mail.domains.all', config('services.ispconfig.cache_ttl'), - fn() => $this->call('mail_domain_get', ['primary_id' => -1]) + fn () => $this->call('mail_domain_get', ['primary_id' => -1]) ); } @@ -30,9 +30,9 @@ class ISPConfigMailService extends ISPConfigService public function getAllMailUsers(): array { return Cache::remember( - "ispconfig.mail.users.all", + 'ispconfig.mail.users.all', config('services.ispconfig.cache_ttl'), - fn() => $this->call('mail_user_get', ['primary_id' => -1]) + fn () => $this->call('mail_user_get', ['primary_id' => -1]) ); } @@ -56,7 +56,7 @@ class ISPConfigMailService extends ISPConfigService $allUsers = $this->getAllMailUsers(); return collect($allUsers)->filter(function ($user) use ($domain) { - return str_ends_with($user['email'], '@' . $domain); + return str_ends_with($user['email'], '@'.$domain); }); } @@ -69,7 +69,7 @@ class ISPConfigMailService extends ISPConfigService $user = collect($allUsers)->firstWhere('email', $email); - if (!$user) { + if (! $user) { return null; } @@ -97,7 +97,7 @@ class ISPConfigMailService extends ISPConfigService $user = collect($this->getAllMailUsers()) ->firstWhere('email', $email); - if (!$user) { + if (! $user) { return false; } @@ -105,10 +105,10 @@ class ISPConfigMailService extends ISPConfigService // On récupère l'enregistrement COMPLET (OBLIGATOIRE) $mailUserRecord = $this->call('mail_user_get', [ - $mailuserId + $mailuserId, ]); - if (!is_array($mailUserRecord)) { + if (! is_array($mailUserRecord)) { throw new \RuntimeException('mail_user_get did not return array'); } @@ -121,11 +121,9 @@ class ISPConfigMailService extends ISPConfigService $result = $this->call('mail_user_update', [ 0, // client_id (ADMIN) $mailuserId, // primary_id - $mailUserRecord // FULL RECORD + $mailUserRecord, // FULL RECORD ]); return (bool) $result; } - - } diff --git a/app/Services/ISPConfig/ISPConfigService.php b/app/Services/ISPConfig/ISPConfigService.php index a0f7720..736e2b3 100644 --- a/app/Services/ISPConfig/ISPConfigService.php +++ b/app/Services/ISPConfig/ISPConfigService.php @@ -4,15 +4,18 @@ namespace App\Services\ISPConfig; use Exception; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use SoapClient; use SoapFault; -use Illuminate\Support\Facades\Log; class ISPConfigService { protected ?SoapClient $client = null; + protected ?string $sessionId = null; + protected array $config; + protected string $serverType; /** @@ -23,7 +26,7 @@ class ISPConfigService $this->serverType = $serverType; $this->config = config("services.ispconfig.servers.{$serverType}"); - if (!$this->config) { + if (! $this->config) { throw new Exception("ISPConfig server configuration not found for: {$serverType}"); } } @@ -44,8 +47,8 @@ class ISPConfigService 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, - ] - ]) + ], + ]), ]); $this->sessionId = $this->client->login( @@ -53,17 +56,17 @@ class ISPConfigService $this->config['password'] ); - Log::info("ISPConfig connected", [ + Log::info('ISPConfig connected', [ 'server' => $this->serverType, - 'session_id' => $this->sessionId + 'session_id' => $this->sessionId, ]); } catch (SoapFault $e) { - Log::error("ISPConfig connection failed", [ + Log::error('ISPConfig connection failed', [ 'server' => $this->serverType, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); - throw new Exception("Failed to connect to ISPConfig: " . $e->getMessage()); + throw new Exception('Failed to connect to ISPConfig: '.$e->getMessage()); } } @@ -72,11 +75,11 @@ class ISPConfigService if ($this->client && $this->sessionId) { try { $this->client->logout($this->sessionId); - Log::info("ISPConfig disconnected", ['server' => $this->serverType]); + Log::info('ISPConfig disconnected', ['server' => $this->serverType]); } catch (SoapFault $e) { - Log::warning("ISPConfig logout failed", [ + Log::warning('ISPConfig logout failed', [ 'server' => $this->serverType, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); } @@ -97,21 +100,21 @@ class ISPConfigService $result = $this->client->__soapCall($method, $params); - Log::debug("ISPConfig API call", [ + Log::debug('ISPConfig API call', [ 'method' => $method, 'server' => $this->serverType, - 'success' => true + 'success' => true, ]); return $result; } catch (SoapFault $e) { - Log::error("ISPConfig API call failed", [ + Log::error('ISPConfig API call failed', [ 'method' => $method, 'server' => $this->serverType, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); - throw new Exception("ISPConfig API call failed: " . $e->getMessage()); + throw new Exception('ISPConfig API call failed: '.$e->getMessage()); } } @@ -126,9 +129,9 @@ class ISPConfigService public function getAllClients(): array { return Cache::remember( - "ispconfig.mail.clients.all", + 'ispconfig.mail.clients.all', config('services.ispconfig.cache_ttl'), - fn() => $this->call('client_get_all') + fn () => $this->call('client_get_all') ); } diff --git a/app/Services/MemberRenewalService.php b/app/Services/MemberRenewalService.php new file mode 100644 index 0000000..f697393 --- /dev/null +++ b/app/Services/MemberRenewalService.php @@ -0,0 +1,152 @@ + $member->id, 'renewal_year' => $year], + ); + + if ($reminder->reminder_1_sent) { + return false; + } + + if ($this->hasActiveMembershipForYear($member, $year)) { + return false; + } + + if (! $this->shouldSendRenewalForYear($member, $year)) { + return false; + } + + $member->notify(new MemberRenewalReminderNotification($member)); + + $reminder->markAsReminder1Sent(); + + return true; + } + + public function sendReminder2(Member $member, int $year): bool + { + $reminder = MemberRenewalReminder::firstOrCreate( + ['member_id' => $member->id, 'renewal_year' => $year], + ); + + if ($reminder->reminder_2_sent) { + return false; + } + + if ($this->hasActiveMembershipForYear($member, $year)) { + return false; + } + + if (! $this->shouldSendRenewalForYear($member, $year)) { + return false; + } + + $member->notify(new MemberRenewalReminder2Notification($member)); + + $reminder->markAsReminder2Sent(); + + return true; + } + + public function sendReminder3(Member $member, int $year): bool + { + $reminder = MemberRenewalReminder::firstOrCreate( + ['member_id' => $member->id, 'renewal_year' => $year], + ); + + if ($reminder->reminder_3_sent) { + return false; + } + + if ($this->hasActiveMembershipForYear($member, $year)) { + return false; + } + + if (! $this->shouldSendRenewalForYear($member, $year)) { + return false; + } + + $member->notify(new MemberRenewalReminder3Notification($member)); + + $reminder->markAsReminder3Sent(); + + return true; + } + + public function deactivateMember(Member $member, int $year): bool + { + $reminder = MemberRenewalReminder::firstOrCreate( + ['member_id' => $member->id, 'renewal_year' => $year], + ); + + if ($reminder->deactivation_sent) { + return false; + } + + if ($this->hasActiveMembershipForYear($member, $year)) { + return false; + } + + $this->memberService->deactivateMember($member); + + $member->notify(new MemberAccountDeactivatedNotification($member)); + + $reminder->markAsDeactivated(); + + return true; + } + + protected function hasActiveMembershipForYear(Member $member, int $year): bool + { + $yearStart = "{$year}-01-01"; + $yearEnd = "{$year}-12-31"; + + return $member->memberships() + ->where('status', 'active') + ->where(function ($query) use ($yearStart, $yearEnd) { + $query->where(function ($q) use ($yearStart, $yearEnd) { + $q->where('start_date', '<=', $yearEnd) + ->where(function ($q2) use ($yearStart) { + $q2->whereNull('end_date') + ->orWhere('end_date', '>=', $yearStart); + }); + }); + }) + ->exists(); + } + + protected function shouldSendRenewalForYear(Member $member, int $year): bool + { + $currentMembership = $member->memberships() + ->where('status', 'active') + ->orderBy('end_date', 'desc') + ->first(); + + if (! $currentMembership || ! $currentMembership->end_date) { + return false; + } + + $expirationYear = (int) date('Y', strtotime($currentMembership->end_date)); + + return $expirationYear === ($year - 1); + } +} diff --git a/app/Services/Nextcloud/NextcloudService.php b/app/Services/Nextcloud/NextcloudService.php index 056ea66..736b481 100644 --- a/app/Services/Nextcloud/NextcloudService.php +++ b/app/Services/Nextcloud/NextcloudService.php @@ -22,31 +22,34 @@ class NextcloudService 'OCS-APIRequest' => 'true', 'Accept' => 'application/json', ]) - ->baseUrl(config('services.nextcloud.url') . '/ocs/v1.php'); + ->baseUrl(config('services.nextcloud.url').'/ocs/v1.php'); } /** * Disable user by email + * * @throws ConnectionException */ public function disableUserByEmail(string $email): void { $userId = $this->findUserIdByEmail($email); - if (!$userId) { - Log::warning("Utilisateur Nextcloud introuvable", ['email' => $email]); + if (! $userId) { + Log::warning('Utilisateur Nextcloud introuvable', ['email' => $email]); + return; } $response = $this->http->put("/cloud/users/{$userId}/disable"); - if (!$response->successful()) { + if (! $response->successful()) { throw new \RuntimeException("Erreur désactivation Nextcloud {$userId}"); } } /** * Desable user by id + * * @throws ConnectionException */ public function disableUserById(string $userId): void @@ -60,7 +63,7 @@ class NextcloudService protected function findUserIdByEmail(string $email): ?string { return Cache::remember( - 'nextcloud.user_id.' . md5($email), + 'nextcloud.user_id.'.md5($email), now()->addDays(7), function () use ($email) { return $this->resolveUserIdByEmail($email); @@ -110,6 +113,4 @@ class NextcloudService ->get("/cloud/users/{$userId}") ->json('ocs.data') ?? []; } - - } diff --git a/database/migrations/2025_10_20_111419_create_members_table.php b/database/migrations/2025_10_20_111419_create_members_table.php index 34ef802..a18d9ca 100644 --- a/database/migrations/2025_10_20_111419_create_members_table.php +++ b/database/migrations/2025_10_20_111419_create_members_table.php @@ -29,7 +29,7 @@ return new class extends Migration 'valid', 'pending', 'cancelled', - 'excluded' + 'excluded', ])->default('draft'); // Nature diff --git a/database/migrations/2025_10_20_151021_create_services_memberships_table.php b/database/migrations/2025_10_20_151021_create_services_memberships_table.php index 608c4fc..8d9b84b 100644 --- a/database/migrations/2025_10_20_151021_create_services_memberships_table.php +++ b/database/migrations/2025_10_20_151021_create_services_memberships_table.php @@ -15,7 +15,7 @@ return new class extends Migration $table->id(); $table->foreignId('service_id')->constrained('services')->onDelete('cascade'); $table->foreignId('membership_id')->constrained('memberships')->onDelete('cascade'); - //$table->json('data')->nullable(); + // $table->json('data')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2026_09_06_140202_create_member_renewal_reminders_table.php b/database/migrations/2026_09_06_140202_create_member_renewal_reminders_table.php new file mode 100644 index 0000000..cfeacb5 --- /dev/null +++ b/database/migrations/2026_09_06_140202_create_member_renewal_reminders_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->year('renewal_year'); + + $table->boolean('reminder_1_sent')->default(false); + $table->boolean('reminder_2_sent')->default(false); + $table->boolean('reminder_3_sent')->default(false); + $table->boolean('deactivation_sent')->default(false); + + $table->timestamp('reminder_1_sent_at')->nullable(); + $table->timestamp('reminder_2_sent_at')->nullable(); + $table->timestamp('reminder_3_sent_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + + $table->timestamps(); + + $table->unique(['member_id', 'renewal_year']); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_renewal_reminders'); + } +}; diff --git a/database/seeders/MemberTypeSeeder.php b/database/seeders/MemberTypeSeeder.php index 8ac8a57..41fa361 100644 --- a/database/seeders/MemberTypeSeeder.php +++ b/database/seeders/MemberTypeSeeder.php @@ -37,4 +37,4 @@ class MemberTypeSeeder extends Seeder ); } } -} \ No newline at end of file +} diff --git a/database/seeders/NotificationTemplateSeeder.php b/database/seeders/NotificationTemplateSeeder.php index 944500b..5d61ade 100644 --- a/database/seeders/NotificationTemplateSeeder.php +++ b/database/seeders/NotificationTemplateSeeder.php @@ -172,13 +172,67 @@ class NotificationTemplateSeeder extends Seeder NotificationTemplate::updateOrCreate( ['identifier' => 'member_renewal_reminder'], [ - 'name' => 'Relance adhésion — générique', - 'subject' => 'Rappel concernant votre adhésion — {app_name}', + 'name' => 'Relance renouvellement — 1ère relance (début décembre)', + 'subject' => 'Renouvellement de votre adhésion pour l\'année à venir — {app_name}', 'body' => '

Bonjour {member_name},

' - .'

Nous vous contactons au sujet de votre adhésion au sein de notre association.

' - .'

Si votre adhésion est arrivée à expiration ou si vous n\'avez pas encore finalisé votre inscription, ' - .'nous vous invitons à régulariser votre situation afin de continuer à bénéficier de l\'ensemble de nos services.

' - .'

Pour toute question ou besoin d\'assistance, n\'hésitez pas à nous contacter.

' + .'

L\'année touche à sa fin et nous vous invitons à renouveler votre adhésion pour l\'année prochaine.

' + .'

Cela vous permettra de continuer à bénéficier de l\'ensemble de nos services sans interruption.

' + .'

Nous restons à votre disposition pour toute question.

' + .'

Cordialement,
L\'équipe {app_name}

', + 'variables' => [ + 'member_name' => 'Nom complet du membre', + 'app_name' => 'Nom de l\'application', + ], + 'is_active' => true, + ] + ); + + NotificationTemplate::updateOrCreate( + ['identifier' => 'member_renewal_reminder_2'], + [ + 'name' => 'Relance renouvellement — 2ème relance (mi-décembre)', + 'subject' => 'Pensez à renouveler votre adhésion — {app_name}', + 'body' => '

Bonjour {member_name},

' + .'

Nous vous avons récemment contacté concernant le renouvellement de votre adhésion pour l\'année prochaine.

' + .'

Si vous n\'avez pas encore renouvelé, nous vous encourageons à le faire rapidement pour garantir la continuité de vos services.

' + .'

N\'hésitez pas à nous contacter si vous avez des questions.

' + .'

Cordialement,
L\'équipe {app_name}

', + 'variables' => [ + 'member_name' => 'Nom complet du membre', + 'app_name' => 'Nom de l\'application', + ], + 'is_active' => true, + ] + ); + + NotificationTemplate::updateOrCreate( + ['identifier' => 'member_renewal_reminder_3'], + [ + 'name' => 'Relance renouvellement — 3ème relance (début janvier)', + 'subject' => 'Dernière relance : renouvellement de votre adhésion — {app_name}', + 'body' => '

Bonjour {member_name},

' + .'

Nous vous contactons une dernière fois concernant le renouvellement de votre adhésion.

' + .'

Sans action de votre part d\'ici la fin janvier, votre compte et vos services seront automatiquement désactivés.

' + .'

Pour éviter toute interruption, nous vous invitons à renouveler dès maintenant.

' + .'

Pour toute question, n\'hésitez pas à nous contacter.

' + .'

Cordialement,
L\'équipe {app_name}

', + 'variables' => [ + 'member_name' => 'Nom complet du membre', + 'app_name' => 'Nom de l\'application', + ], + 'is_active' => true, + ] + ); + + NotificationTemplate::updateOrCreate( + ['identifier' => 'member_account_deactivated'], + [ + 'name' => 'Compte désactivé pour non-renouvellement', + 'subject' => 'Votre compte {app_name} a été désactivé', + 'body' => '

Bonjour {member_name},

' + .'

Faute de renouvellement de votre adhésion, votre compte et vos services ont été désactivés.

' + .'

Si vous souhaitez réactiver votre compte, n\'hésitez pas à nous contacter.

' + .'

Nous vous remercions pour la confiance que vous nous avez accordée.

' .'

Cordialement,
L\'équipe {app_name}

', 'variables' => [ 'member_name' => 'Nom complet du membre', diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 50ed67d..b40041d 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -42,4 +42,4 @@ class PackageSeeder extends Seeder ); } } -} \ No newline at end of file +} diff --git a/deploy.php b/deploy.php index ebd8d2c..c14a282 100644 --- a/deploy.php +++ b/deploy.php @@ -1,4 +1,5 @@ $_SERVER[$env . '_SSH_HOST'], - 'port' => $_SERVER[$env . '_SSH_PORT'] ?? 22, - 'user' => $_SERVER[$env . '_SSH_USER'], - 'path' => $_SERVER[$env . '_SSH_PATH'], + 'host' => $_SERVER[$env.'_SSH_HOST'], + 'port' => $_SERVER[$env.'_SSH_PORT'] ?? 22, + 'user' => $_SERVER[$env.'_SSH_USER'], + 'path' => $_SERVER[$env.'_SSH_PATH'], 'repository' => $_SERVER['CI_REPOSITORY_URL'], ]; diff --git a/lang/en/contacts.php b/lang/en/contacts.php index 4b37f6a..333c3d0 100644 --- a/lang/en/contacts.php +++ b/lang/en/contacts.php @@ -4,9 +4,9 @@ return [ 'fields' => [ 'contact' => 'Contact', 'contacts' => 'Contacts', - ], + ], 'responses' => [ 'success' => 'Your message has been sent successfully', 'error' => 'An error occurred while sending the message', - ] + ], ]; diff --git a/lang/en/member_groups.php b/lang/en/member_groups.php index ee22184..9b6c06d 100644 --- a/lang/en/member_groups.php +++ b/lang/en/member_groups.php @@ -9,6 +9,6 @@ return [ 'description' => 'Description', 'created_at' => 'Created at', 'Updated at' => 'Updated at', - 'view_members' => 'View members of this group' + 'view_members' => 'View members of this group', ], ]; diff --git a/lang/en/packages.php b/lang/en/packages.php index 5ac9b79..a5f5d3e 100644 --- a/lang/en/packages.php +++ b/lang/en/packages.php @@ -8,5 +8,5 @@ return [ 'name' => 'Name', 'description' => 'Description', 'is_active' => 'Active', - ] + ], ]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php index 0462488..c1d80d2 100644 --- a/lang/en/passwords.php +++ b/lang/en/passwords.php @@ -1,10 +1,10 @@ 'Your password has been reset.', - 'sent' => 'We have emailed your password reset link.', - 'sent_if_exists' => 'A reset link will be sent if the account exists.', - 'throttled' => 'Please wait before retrying.', - 'token' => 'This password reset token is invalid.', - 'user' => 'We can\'t find a user with that email address.', + 'reset' => 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'sent_if_exists' => 'A reset link will be sent if the account exists.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => 'We can\'t find a user with that email address.', ]; diff --git a/lang/en/services.php b/lang/en/services.php index 041ba83..1b29bbc 100644 --- a/lang/en/services.php +++ b/lang/en/services.php @@ -9,5 +9,5 @@ return [ 'description' => 'Description', 'url' => 'URL', 'icon' => 'Icon', - ] + ], ]; diff --git a/lang/fr/contacts.php b/lang/fr/contacts.php index 6715513..f22babe 100644 --- a/lang/fr/contacts.php +++ b/lang/fr/contacts.php @@ -4,9 +4,9 @@ return [ 'fields' => [ 'contact' => 'un contact', 'contacts' => 'Contacts', - ], + ], 'responses' => [ 'success' => 'Votre message a été envoyé avec succès. Nous vous recontacterons dans les plus brefs délais.', 'error' => 'Une erreur est survenue lors de l\'envoi du message', - ] + ], ]; diff --git a/lang/fr/member_groups.php b/lang/fr/member_groups.php index a6d5efe..096d7d7 100644 --- a/lang/fr/member_groups.php +++ b/lang/fr/member_groups.php @@ -9,6 +9,6 @@ return [ 'description' => 'Description', 'created_at' => 'Créé le', 'updated_at' => 'Mis à jour le', - 'view_members' => 'Voir les membres de ce groupe' + 'view_members' => 'Voir les membres de ce groupe', ], ]; diff --git a/lang/fr/packages.php b/lang/fr/packages.php index aefaa26..f7d7c4c 100644 --- a/lang/fr/packages.php +++ b/lang/fr/packages.php @@ -1,12 +1,12 @@ [ - 'package' => 'Package', - 'packages' => 'Packages', - 'identifier' => 'Identifiant', - 'name' => 'Nom', - 'description' => 'Description', - 'is_active' => 'Actif', - ] + 'fields' => [ + 'package' => 'Package', + 'packages' => 'Packages', + 'identifier' => 'Identifiant', + 'name' => 'Nom', + 'description' => 'Description', + 'is_active' => 'Actif', + ], ]; diff --git a/lang/fr/passwords.php b/lang/fr/passwords.php index 0d9d42a..3ae445c 100644 --- a/lang/fr/passwords.php +++ b/lang/fr/passwords.php @@ -1,10 +1,10 @@ 'Votre mot de passe a été réinitialisé.', - 'sent' => 'Nous vous avons envoyé le lien de réinitialisation par e-mail.', - 'sent_if_exists' => 'Un lien de réinitialisation sera envoyé si le compte existe.', - 'throttled' => 'Veuillez patienter avant de réessayer.', - 'token' => 'Ce jeton de réinitialisation est invalide.', - 'user' => 'Aucun utilisateur trouvé avec cette adresse e-mail.', -]; \ No newline at end of file + 'reset' => 'Votre mot de passe a été réinitialisé.', + 'sent' => 'Nous vous avons envoyé le lien de réinitialisation par e-mail.', + 'sent_if_exists' => 'Un lien de réinitialisation sera envoyé si le compte existe.', + 'throttled' => 'Veuillez patienter avant de réessayer.', + 'token' => 'Ce jeton de réinitialisation est invalide.', + 'user' => 'Aucun utilisateur trouvé avec cette adresse e-mail.', +]; diff --git a/memberships b/memberships new file mode 100644 index 0000000..e69de29 diff --git a/routes/console.php b/routes/console.php index 3c9adf1..8662f72 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,24 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('members:renewal-process --phase=reminder_1') + ->yearlyOn(12, 1, '09:00') + ->description('1ère relance renouvellement adhésion'); + +Schedule::command('members:renewal-process --phase=reminder_2') + ->yearlyOn(12, 15, '09:00') + ->description('2ème relance renouvellement adhésion'); + +Schedule::command('members:renewal-process --phase=reminder_3') + ->yearlyOn(1, 5, '09:00') + ->description('3ème relance renouvellement adhésion'); + +Schedule::command('members:renewal-process --phase=deactivate') + ->yearlyOn(1, 28, '09:00') + ->description('Désactivation comptes non renouvelés');