From 6754d8684a32f2fcc0bdd949ad4f71f04caf7e73 Mon Sep 17 00:00:00 2001 From: Nebulae Date: Tue, 7 Apr 2026 16:52:18 +0200 Subject: [PATCH] feat&fix(add Listmonk service and sync part 1, fix association email on member sync) --- .gitignore | 1 + .../Commands/HandleExpiredMembersDolibarr.php | 36 ++- app/Console/Commands/SyncDolibarrMembers.php | 2 +- .../Commands/SyncISPConfigMailMembers.php | 27 ++- app/Console/Commands/SyncListmonkMembers.php | 119 ++++++++++ app/Console/Commands/SyncNextcloudMembers.php | 12 +- app/Filament/Pages/Synchronisations.php | 12 + app/Models/ListmonkMember.php | 40 ++++ app/Models/Member.php | 17 +- app/Services/ListMonk/ListMonkService.php | 206 ++---------------- ..._140903_create_listmonks_members_table.php | 30 +++ lang/en/synchronisations.php | 7 + lang/fr/synchronisations.php | 7 + .../filament/pages/synchronisations.blade.php | 22 ++ 14 files changed, 298 insertions(+), 240 deletions(-) create mode 100644 app/Console/Commands/SyncListmonkMembers.php create mode 100644 app/Models/ListmonkMember.php create mode 100644 database/migrations/2026_04_07_140903_create_listmonks_members_table.php diff --git a/.gitignore b/.gitignore index 976aa52..f967945 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.log +TODO.txt .DS_Store .env .env.backup diff --git a/app/Console/Commands/HandleExpiredMembersDolibarr.php b/app/Console/Commands/HandleExpiredMembersDolibarr.php index 63b4bf3..2efc5d9 100644 --- a/app/Console/Commands/HandleExpiredMembersDolibarr.php +++ b/app/Console/Commands/HandleExpiredMembersDolibarr.php @@ -19,12 +19,11 @@ class HandleExpiredMembersDolibarr extends Command {--dry-run}'; public function __construct( - protected DolibarrService $dolibarr, + protected DolibarrService $dolibarr, protected ISPConfigMailService $mailService, - protected NextcloudService $nextcloud, - protected MemberService $memberService - ) - { + protected NextcloudService $nextcloud, + protected MemberService $memberService + ) { parent::__construct(); } @@ -65,11 +64,12 @@ class HandleExpiredMembersDolibarr extends Command if ($emailFilter) { $expiredMembers = $expiredMembers->filter(function ($member) use ($emailFilter) { - return $this->extractRetzienEmail($member['email'] ?? null) === $emailFilter; + return Member::extractRetzienEmail($member['email'] ?? '') === $emailFilter; }); if ($expiredMembers->isEmpty()) { $this->warn("Aucun adhérent expiré trouvé pour {$emailFilter}"); + return CommandAlias::SUCCESS; } } @@ -102,13 +102,13 @@ class HandleExpiredMembersDolibarr extends Command */ protected function processMember(array $member, bool $dryRun): void { - $email = $this->extractRetzienEmail($member['email'] ?? null); + $email = Member::extractRetzienEmail($member['email'] ?? ''); $this->line("• {$member['id']} - {$email}"); // Résiliation Dolibarr if ($dryRun) { - $this->info("[DRY-RUN] Résiliation Dolibarr"); + $this->info('[DRY-RUN] Résiliation Dolibarr'); } else { $this->dolibarr->updateMember($member['id'], [ 'statut' => 0, @@ -129,7 +129,7 @@ class HandleExpiredMembersDolibarr extends Command // Désactivation Nextcloud if ($email) { if ($dryRun) { - $this->info("[DRY-RUN] Désactivation Nextcloud"); + $this->info('[DRY-RUN] Désactivation Nextcloud'); } else { $this->nextcloud->disableUserByEmail($email); } @@ -143,13 +143,15 @@ class HandleExpiredMembersDolibarr extends Command { $details = $this->mailService->getMailUserDetails($email); - if (!$details) { + if (! $details) { $this->warn("Boîte mail inexistante : {$email}"); + return; } if ($dryRun) { $this->info("[DRY-RUN] Mail désactivé ({$email})"); + return; } @@ -160,22 +162,10 @@ class HandleExpiredMembersDolibarr extends Command 'disablepop3' => 'y', ]); - if (!$result) { + if (! $result) { throw new \RuntimeException("Échec désactivation mail ISPConfig pour {$email}"); } $this->info("Mail désactivé : {$email}"); } - - protected function extractRetzienEmail(?string $emails): ?string - { - if (!$emails) { - return null; - } - - return collect(explode(';', $emails)) - ->map(fn(string $email): string => trim($email)) - ->filter(fn(string $email): bool => str_contains($email, '@retzien.fr')) - ->first(); - } } diff --git a/app/Console/Commands/SyncDolibarrMembers.php b/app/Console/Commands/SyncDolibarrMembers.php index d049b4a..aa7863d 100644 --- a/app/Console/Commands/SyncDolibarrMembers.php +++ b/app/Console/Commands/SyncDolibarrMembers.php @@ -68,7 +68,7 @@ class SyncDolibarrMembers extends Command 'lastname' => $member['lastname'], 'firstname' => $member['firstname'], 'email' => $member['email'] ?: null, - 'retzien_email' => '', + 'retzien_email' => Member::extractRetzienEmail($member['email'] ?? ''), 'company' => $member['societe'], 'website_url' => $member['url'], 'address' => $member['address'], diff --git a/app/Console/Commands/SyncISPConfigMailMembers.php b/app/Console/Commands/SyncISPConfigMailMembers.php index 25ef14d..85b1667 100644 --- a/app/Console/Commands/SyncISPConfigMailMembers.php +++ b/app/Console/Commands/SyncISPConfigMailMembers.php @@ -7,20 +7,22 @@ use App\Models\IspconfigMember; use App\Models\Member; use App\Services\ISPConfig\ISPConfigMailService; use Illuminate\Console\Command; + use function Laravel\Prompts\progress; class SyncISPConfigMailMembers extends Command { protected $signature = 'sync:ispconfig-mail-members'; + protected $description = 'Synchronise les services MAIL ISPConfig des membres - Email Retzien'; public function handle(): void { $this->info('Synchronisation ISPConfig MAIL'); - $ispMail = new ISPConfigMailService(); + $ispMail = new ISPConfigMailService; - //Récupération de tous les mail users + // Récupération de tous les mail users $mailUsers = collect($ispMail->getAllMailUsers()); $progressBar = progress(label: 'ISPConfig Mail Members import', steps: $mailUsers->count()); @@ -35,40 +37,37 @@ class SyncISPConfigMailMembers extends Command $synced = 0; // Parcours des membres - Member::whereNotNull('email')->chunk(100, function ($members) use ( + Member::whereNotNull('retzien_email')->where('retzien_email', '!=', '')->chunk(100, function ($members) use ( $progressBar, $emailToMailUserId, $ispMail, &$synced ) { foreach ($members as $member) { - $emails = array_map('trim', explode(';', $member->email)); + $retzienEmail = strtolower($member->retzien_email); - $retzienEmail = collect($emails) - ->map(fn ($e) => strtolower($e)) - ->first(fn ($e) => str_ends_with($e, '@retzien.fr')); - - if (!$retzienEmail) { + if (! $retzienEmail) { continue; } $mailUserId = $emailToMailUserId->get($retzienEmail); - if (!$mailUserId) { + if (! $mailUserId) { $this->warn("Aucun mail user ISPConfig pour {$retzienEmail}"); + continue; } - //Récupération des données complètes de la boîte mail + // Récupération des données complètes de la boîte mail $mailUserData = $ispMail->getMailUserDetails($retzienEmail); // Création / mise à jour IspconfigMember::updateOrCreate( [ 'member_id' => $member->id, - //@todo : 'ispconfig_client_id' => ?, - 'type' => IspconfigType::MAIL, - 'email' => $retzienEmail, + // @todo : 'ispconfig_client_id' => ?, + 'type' => IspconfigType::MAIL, + 'email' => $retzienEmail, ], [ 'ispconfig_service_user_id' => $mailUserId, diff --git a/app/Console/Commands/SyncListmonkMembers.php b/app/Console/Commands/SyncListmonkMembers.php new file mode 100644 index 0000000..198c706 --- /dev/null +++ b/app/Console/Commands/SyncListmonkMembers.php @@ -0,0 +1,119 @@ +option('dry-run'); + $memberFilter = $this->option('member'); + + $this->info( + $dryRun + ? 'DRY-RUN enabled' + : 'Syncing Listmonk → Members' + ); + + $members = Member::query() + ->when($memberFilter, fn ($q) => $q->where('id', $memberFilter)) + ->get() + ->filter(fn (Member $m) => ! empty($m->retzien_email)) + ->keyBy(fn (Member $m) => strtolower($m->retzien_email)); + + if ($members->isEmpty()) { + $this->warn('No members to sync'); + + return CommandAlias::SUCCESS; + } + + $this->info("{$members->count()} members to sync"); + + $listmonkUsers = $this->listmonk->getUsers(); + + dd($listmonkUsers); + + $this->info(count($listmonkUsers).' Listmonk users found'); + + $progress = null; + + if (! $dryRun) { + $progress = progress( + label: 'Syncing members', + steps: $members->count() + ); + $progress->start(); + } + + $synced = 0; + + foreach ($listmonkUsers as $user) { + try { + $email = strtolower($user['email'] ?? ''); + + if (! $email || ! $members->has($email)) { + continue; + } + + $member = $members[$email]; + + if ($dryRun) { + $this->line("[DRY-RUN] {$member->id} ({$email}) ← Listmonk user #{$user['id']}"); + } else { + ListmonkMember::query()->updateOrCreate( + ['member_id' => $member->id], + [ + 'listmonk_user_id' => $user['id'], + 'data' => $user, + ] + ); + + $progress?->advance(); + } + + $synced++; + } catch (\Throwable $e) { + Log::error('Listmonk sync error', [ + 'user' => $user['id'] ?? null, + 'error' => $e->getMessage(), + ]); + + $progress?->advance(); + } + } + + if ($progress) { + $progress->finish(); + $this->newLine(); + } + + $this->info("Sync complete — {$synced} accounts linked"); + + return CommandAlias::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncNextcloudMembers.php b/app/Console/Commands/SyncNextcloudMembers.php index bf2b34f..2818d38 100644 --- a/app/Console/Commands/SyncNextcloudMembers.php +++ b/app/Console/Commands/SyncNextcloudMembers.php @@ -9,6 +9,7 @@ use Illuminate\Console\Command; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Log; use Symfony\Component\Console\Command\Command as CommandAlias; + use function Laravel\Prompts\progress; class SyncNextcloudMembers extends Command @@ -40,14 +41,15 @@ class SyncNextcloudMembers extends Command ); $members = Member::query() - ->where('email', 'like', '%@retzien.fr%') + ->whereNotNull('retzien_email') + ->where('retzien_email', '!=', '') ->when($memberFilter, fn ($q) => $q->where('id', $memberFilter)) ->get() - ->filter(fn (Member $m) => !empty($m->retzien_email)) ->keyBy(fn (Member $m) => strtolower($m->retzien_email)); if ($members->isEmpty()) { $this->warn('Aucun membre à synchroniser'); + return CommandAlias::SUCCESS; } @@ -55,11 +57,11 @@ class SyncNextcloudMembers extends Command $userIds = $this->nextcloud->listUsers(); - $this->info(count($userIds) . ' comptes Nextcloud trouvés'); + $this->info(count($userIds).' comptes Nextcloud trouvés'); $progress = null; - if (!$dryRun) { + if (! $dryRun) { $progress = progress( label: 'Synchronisation des membres', steps: $members->count() @@ -75,7 +77,7 @@ class SyncNextcloudMembers extends Command $email = strtolower($details['email'] ?? ''); - if (!$email || !$members->has($email)) { + if (! $email || ! $members->has($email)) { continue; } diff --git a/app/Filament/Pages/Synchronisations.php b/app/Filament/Pages/Synchronisations.php index 775679a..840c941 100644 --- a/app/Filament/Pages/Synchronisations.php +++ b/app/Filament/Pages/Synchronisations.php @@ -24,6 +24,7 @@ class Synchronisations extends Page 'ispconfig_mail', 'ispconfig_web', 'nextcloud', + 'listmonk', 'services', ]; @@ -154,6 +155,17 @@ class Synchronisations extends Page }); } + public function syncListmonkAction(): Action + { + return Action::make('syncListmonk') + ->requiresConfirmation() + ->modalHeading(__('synchronisations.sections.listmonk.modal_heading')) + ->modalDescription(__('synchronisations.sections.listmonk.modal_description')) + ->modalSubmitActionLabel(__('synchronisations.action.submit')) + ->disabled(fn () => in_array($this->getCommandStatus('listmonk')['status'], ['pending', 'running'])) + ->action(fn () => $this->enqueueCommand('listmonk', 'listmonk:sync-members')); + } + public function syncServicesAction(): Action { return Action::make('syncServices') diff --git a/app/Models/ListmonkMember.php b/app/Models/ListmonkMember.php new file mode 100644 index 0000000..e37cf4e --- /dev/null +++ b/app/Models/ListmonkMember.php @@ -0,0 +1,40 @@ +|null $data + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Member $member + * + * @mixin \Eloquent + */ +class ListmonkMember extends Model +{ + protected $table = 'listmonks_members'; + + protected $fillable = [ + 'member_id', + 'listmonk_user_id', + 'data', + ]; + + protected function casts(): array + { + return [ + 'data' => 'array', + ]; + } + + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php index 634e759..ce7e653 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -113,18 +113,19 @@ class Member extends Model return __("members.fields.$attribute"); } + public static function extractRetzienEmail(string $rawEmails): ?string + { + return collect(explode(';', $rawEmails)) + ->map(fn (string $email) => trim($email)) + ->filter(fn (string $email) => str_ends_with($email, '@retzien.fr')) + ->first(); + } + public function getFullNameAttribute(): string { return "{$this->firstname} {$this->lastname}"; } - public function getRetzienEmailAttribute(): ?string - { - $emails = explode(';', $this->email); - - return collect($emails)->filter(fn ($email) => str_contains($email, '@retzien.fr'))->first(); - } - public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -164,7 +165,7 @@ class Member extends Model public function isExpired(): bool { - // 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 depuis plus d'un mois $lastMembership = $this->lastMembership(); return $lastMembership->status === 'expired' || $lastMembership->created_at->addMonths(1) < now(); diff --git a/app/Services/ListMonk/ListMonkService.php b/app/Services/ListMonk/ListMonkService.php index 03ac25f..bab56a1 100644 --- a/app/Services/ListMonk/ListMonkService.php +++ b/app/Services/ListMonk/ListMonkService.php @@ -17,15 +17,27 @@ class ListMonkService config('services.listmonk.password') ) ->withHeaders(['Accept' => 'application/json']) - ->baseUrl(config('services.listmonk.base_url').'/api'); + ->baseUrl(config('services.listmonk.base_url')); } - // ------------------------------------------------------------------------- - // Lists - // ------------------------------------------------------------------------- + /** + * Retrieve all Listmonk user accounts. + * + * @return array + * + * @throws ConnectionException + */ + public function getUsers(): array + { + return $this->http + ->get('/users') + ->json('data') ?? []; + } /** - * Retrieve all mailing lists. + * Retrieve all mailing lists with their subscriber counts. + * + * @return array * * @throws ConnectionException */ @@ -35,188 +47,4 @@ class ListMonkService ->get('/lists', ['per_page' => 'all']) ->json('data.results') ?? []; } - - /** - * Retrieve a single list by its ID. - * - * @throws ConnectionException - */ - public function getList(int $listId): ?array - { - $response = $this->http->get("/lists/{$listId}"); - - if (! $response->successful()) { - return null; - } - - return $response->json('data'); - } - - // ------------------------------------------------------------------------- - // Subscribers - // ------------------------------------------------------------------------- - - /** - * Retrieve subscribers with optional pagination. - * - * @throws ConnectionException - */ - public function getSubscribers(int $page = 1, int $perPage = 100): array - { - return $this->http - ->get('/subscribers', [ - 'page' => $page, - 'per_page' => $perPage, - ]) - ->json('data.results') ?? []; - } - - /** - * Retrieve a single subscriber by their Listmonk ID. - * - * @throws ConnectionException - */ - public function getSubscriber(int $subscriberId): ?array - { - $response = $this->http->get("/subscribers/{$subscriberId}"); - - if (! $response->successful()) { - return null; - } - - return $response->json('data'); - } - - /** - * Find a subscriber by their email address. - * - * @throws ConnectionException - */ - public function getSubscriberByEmail(string $email): ?array - { - $results = $this->http - ->get('/subscribers', ['query' => "subscribers.email = '{$email}'"]) - ->json('data.results') ?? []; - - return $results[0] ?? null; - } - - /** - * Create a new subscriber and enrol them in the given lists. - * - * @param array $listIds IDs of the lists to subscribe to. - * @param array $attribs Custom attributes (e.g. language preference). - * - * @throws ConnectionException - */ - public function createSubscriber( - string $email, - string $name, - array $listIds = [], - array $attribs = [], - string $status = 'enabled', - ): ?array { - $response = $this->http->post('/subscribers', [ - 'email' => $email, - 'name' => $name, - 'status' => $status, - 'lists' => $listIds, - 'attribs' => $attribs, - ]); - - if (! $response->successful()) { - return null; - } - - return $response->json('data'); - } - - /** - * Update an existing subscriber's information. - * - * @param array $listIds - * @param array $attribs - * - * @throws ConnectionException - */ - public function updateSubscriber( - int $subscriberId, - string $email, - string $name, - array $listIds = [], - array $attribs = [], - string $status = 'enabled', - ): bool { - $response = $this->http->put("/subscribers/{$subscriberId}", [ - 'email' => $email, - 'name' => $name, - 'status' => $status, - 'lists' => $listIds, - 'attribs' => $attribs, - ]); - - return $response->successful(); - } - - /** - * Subscribe or unsubscribe a set of subscribers from lists. - * - * @param array $subscriberIds - * @param array $listIds - * @param string $action subscribe | unsubscribe - * @param string $status confirmed | unconfirmed - * - * @throws ConnectionException - */ - public function updateSubscriberLists( - array $subscriberIds, - array $listIds, - string $action = 'subscribe', - string $status = 'confirmed', - ): bool { - $response = $this->http->put('/subscribers/lists', [ - 'ids' => $subscriberIds, - 'action' => $action, - 'status' => $status, - 'list_ids' => $listIds, - ]); - - return $response->successful(); - } - - /** - * Add a subscriber to the blocklist. - * - * @throws ConnectionException - */ - public function blocklistSubscriber(int $subscriberId): bool - { - return $this->http - ->put("/subscribers/{$subscriberId}/blocklist") - ->successful(); - } - - /** - * Permanently delete a subscriber. - * - * @throws ConnectionException - */ - public function deleteSubscriber(int $subscriberId): bool - { - return $this->http - ->delete("/subscribers/{$subscriberId}") - ->successful(); - } - - /** - * Send an opt-in confirmation email to a subscriber. - * - * @throws ConnectionException - */ - public function sendOptin(int $subscriberId): bool - { - return $this->http - ->post("/subscribers/{$subscriberId}/optin") - ->successful(); - } } diff --git a/database/migrations/2026_04_07_140903_create_listmonks_members_table.php b/database/migrations/2026_04_07_140903_create_listmonks_members_table.php new file mode 100644 index 0000000..2358fbc --- /dev/null +++ b/database/migrations/2026_04_07_140903_create_listmonks_members_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('NO ACTION'); + $table->unsignedInteger('listmonk_user_id')->nullable(); + $table->json('data')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('listmonks_members'); + } +}; diff --git a/lang/en/synchronisations.php b/lang/en/synchronisations.php index 7eef900..fa562fa 100644 --- a/lang/en/synchronisations.php +++ b/lang/en/synchronisations.php @@ -46,6 +46,13 @@ return [ 'dry_run_label' => 'Simulation mode (dry-run)', 'dry_run_helper' => 'Simulates the operation without making any changes.', ], + 'listmonk' => [ + 'heading' => 'Listmonk', + 'description' => 'Link members to their Listmonk subscriber accounts (@retzien.fr).', + 'action_label' => 'Listmonk', + 'modal_heading' => 'Listmonk Synchronisation', + 'modal_description' => 'Link members to their Listmonk subscriber accounts using their @retzien.fr address.', + ], 'services' => [ 'heading' => 'Member Services', 'description' => 'Synchronise services associated with active members.', diff --git a/lang/fr/synchronisations.php b/lang/fr/synchronisations.php index b4fec80..dab0b94 100644 --- a/lang/fr/synchronisations.php +++ b/lang/fr/synchronisations.php @@ -46,6 +46,13 @@ return [ 'dry_run_label' => 'Mode simulation (dry-run)', 'dry_run_helper' => 'Simule l\'opération sans effectuer de modifications.', ], + 'listmonk' => [ + 'heading' => 'Listmonk', + 'description' => 'Lie les membres à leurs comptes abonnés Listmonk (@retzien.fr).', + 'action_label' => 'Listmonk', + 'modal_heading' => 'Synchronisation Listmonk', + 'modal_description' => 'Lie les membres à leurs comptes abonnés Listmonk en se basant sur leur adresse @retzien.fr.', + ], 'services' => [ 'heading' => 'Services membres', 'description' => 'Synchronise les services associés aux membres actifs.', diff --git a/resources/views/filament/pages/synchronisations.blade.php b/resources/views/filament/pages/synchronisations.blade.php index 785d3a0..161c235 100644 --- a/resources/views/filament/pages/synchronisations.blade.php +++ b/resources/views/filament/pages/synchronisations.blade.php @@ -10,6 +10,7 @@ $ispMail = $this->getCommandStatus('ispconfig_mail'); $ispWeb = $this->getCommandStatus('ispconfig_web'); $nextcloud = $this->getCommandStatus('nextcloud'); + $listmonk = $this->getCommandStatus('listmonk'); $services = $this->getCommandStatus('services'); @endphp @@ -118,6 +119,27 @@ + + + @include('filament.pages.partials.sync-heading', [ + 'label' => __('synchronisations.sections.listmonk.heading'), + 'status' => $listmonk, + ]) + +
+

+ {{ __('synchronisations.sections.listmonk.description') }} +

+ @include('filament.pages.partials.sync-status', ['status' => $listmonk]) + + {{ __('synchronisations.action.submit') }} + +
+
+ @include('filament.pages.partials.sync-heading', [