Compare commits

..

3 Commits

Author SHA1 Message Date
ba11ee7120 feat(Reminder process): add 4 reminder phases for membership (Retzien)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s
2026-09-06 17:03:14 +02:00
98893924ac feat(ISPConfig Webhosting): add creation script for members with website
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m23s
2026-08-19 18:31:02 +02:00
7dff670122 fix(Readme)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m19s
2026-08-18 19:51:25 +02:00
68 changed files with 1565 additions and 266 deletions

0
- Normal file
View File

View File

@@ -88,7 +88,7 @@ Elle gère les membres, les cotisations, et s'intègre avec des services tiers (
| MemberService | Inscription et desactivation de membres |
| DolibarrService | Integration ERP via API REST |
| ISPConfigMailService | Gestion comptes mail via SOAP |
| ISPConfigWebService | Gestion hebergement web via SOAP (avec cache) |
| ISPConfigWebService | Gestion hebergement web via SOAP (avec cache). Creation de clients et reassignation de sites |
| NextcloudService | Gestion comptes Nextcloud via OCS (avec cache 7 jours) |
---
@@ -101,6 +101,7 @@ Elle gère les membres, les cotisations, et s'intègre avec des services tiers (
| `members:cleanup-expired` | Desactive les membres expires (Dolibarr + ISPConfig + Nextcloud). `--dry-run` |
| `sync:ispconfig-mail-members` | Lie les membres a leurs comptes mail ISPConfig (@retzien.fr) |
| `sync:ispconfig-web-members` | Lie les membres a leurs comptes d'hebergement web |
| `ext:create-isp-accounts` | Cree les clients ISPConfig pour les membres et reassigne leurs sites. `--dry-run`, `--force` |
| `nextcloud:sync-members` | Lie les membres a leurs comptes Nextcloud |
| `memberships:sync-services` | Synchronise les services associes aux membres |

View File

@@ -1,5 +1,7 @@
# Roxane
![Roxane Illustration](./assets/LRL-roxane.png)
Roxane is an open source membership management application designed for associations. It centralizes member management, subscriptions, and integration with self-hosted third-party services (Dolibarr, ISPConfig, Nextcloud, Sympa).
The project is developed in the context of **Le Retzien Libre**, a non-profit association promoting digital freedom and self-hosting. It is intended to be generic enough to be adapted by other associations with similar needs.

View File

@@ -2,29 +2,311 @@
namespace App\Console\Commands;
use App\Enums\IspconfigType;
use App\Models\IspconfigMember;
use App\Models\Member;
use App\Services\ISPConfig\ISPConfigWebService;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use function Laravel\Prompts\progress;
class CreateISPWebAccounts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ext:create-ispweb-accounts';
protected $signature = 'ext:create-isp-accounts
{--dry-run : Simulate without creating accounts}
{--force : Force recreation even if client already exists}';
protected $description = 'Create ISPConfig clients for members and reassign their websites';
/**
* The console command description.
*
* @var string
* @throws Exception
*/
protected $description = 'Créer les comptes ISPWeb des membres en fonction de leur domaine';
/**
* Execute the console command.
*/
public function handle()
public function handle(): int
{
//
$isDryRun = $this->option('dry-run');
$isForce = $this->option('force');
$this->info('ISPConfig Client Creation & Website Reassignment');
if ($isDryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$ispWeb = new ISPConfigWebService;
// Récupération de tous les membres ayant un site web
$membersQuery = Member::whereNotNull('website_url')
->where('website_url', '!=', '');
$totalMembers = $membersQuery->count();
if ($totalMembers === 0) {
$this->info('No members with websites found');
return self::SUCCESS;
}
$this->info("Found {$totalMembers} members with websites");
// Récupération de tous les sites web depuis ISPConfig (avec cache)
$allWebsites = collect($ispWeb->getAllWebsites());
// Récupération des clients déjà créés dans Roxane (pour éviter les doublons)
$existingClients = IspconfigMember::where('type', IspconfigType::WEB)
->whereIn('member_id', $membersQuery->pluck('id'))
->get()
->keyBy('member_id');
$progressBar = progress(
label: 'Processing members',
steps: $totalMembers
);
$progressBar->start();
// Statistiques de traitement
$stats = [
'clients_created' => 0,
'websites_reassigned' => 0,
'errors' => 0,
'skipped' => 0,
];
// Résultats détaillés pour le mode dry-run
$dryRunResults = [];
// Traitement par lots de 50 membres
$membersQuery->chunk(50, function ($members) use (
$ispWeb,
$allWebsites,
$existingClients,
$progressBar,
$isDryRun,
$isForce,
&$stats,
&$dryRunResults
) {
foreach ($members as $member) {
try {
/** @var IspconfigMember|null $existingClient */
$existingClient = $existingClients->get($member->id);
// Extraction et normalisation des domaines depuis member->website_url
$memberDomains = $this->extractDomains($member->website_url);
if ($memberDomains->isEmpty()) {
$this->warn("No valid domains for member: {$member->full_name}");
$stats['skipped']++;
$progressBar->advance();
continue;
}
// Recherche des sites web ISPConfig correspondant aux domaines du membre
$matchedWebsites = $ispWeb->findWebsitesForDomains($allWebsites, $memberDomains);
if ($matchedWebsites->isEmpty()) {
$this->warn("No ISPConfig websites found for member: {$member->full_name}");
$stats['skipped']++;
$progressBar->advance();
continue;
}
$email = $member->retzien_email ?? $member->email;
$clientInfo = null;
// Vérification 1 : Le client existe-t-il dans ISPConfig ?
$existingIspClient = $ispWeb->findClientByEmail($email);
if ($existingIspClient && ! $isForce) {
// Client trouvé dans ISPConfig par email
$clientInfo = $existingIspClient;
$this->info("ISPConfig client found for {$member->full_name} with email {$email} (Client ID: {$clientInfo['client_id']})");
} elseif ($existingClient && ! $isForce && $existingClient->ispconfig_client_id > 0) {
// Client déjà lié dans Roxane (fallback) - uniquement si client_id valide
$clientInfo = [
'client_id' => (int) $existingClient->ispconfig_client_id,
'groupid' => (int) $existingClient->ispconfig_client_id,
];
$this->info("Member {$member->full_name} already linked to ISPConfig client (ID: {$clientInfo['client_id']})");
}
if (! $isDryRun) {
// MODE EXECUTION RÉELLE
// Création du client ISPConfig si nécessaire
if ($clientInfo === null) {
$clientInfo = $this->createClient($member, $ispWeb, $email);
$stats['clients_created']++;
}
// Réassignation de tous les sites web au client ISPConfig
foreach ($matchedWebsites as $website) {
$domainId = $website['domain_id'];
$ispWeb->updateWebsiteClient($domainId, $clientInfo['groupid']);
$stats['websites_reassigned']++;
$this->info("Reassigned {$website['domain']} to client {$clientInfo['client_id']}");
}
// Sauvegarde dans Roxane de la relation membre <-> client ISPConfig
$siteData = $matchedWebsites->map(fn ($site) => [
'domain_id' => $site['domain_id'],
'domain' => $site['domain'],
])->toArray();
IspconfigMember::updateOrCreate(
[
'member_id' => $member->id,
'type' => IspconfigType::WEB,
],
[
'ispconfig_client_id' => $clientInfo['client_id'],
'data' => [
'sites' => $siteData,
],
]
);
} else {
// MODE DRY-RUN : Collecte des informations sans modifications
$willCreateClient = $clientInfo === null;
if ($willCreateClient) {
$stats['clients_created']++;
}
$dryRunResults[] = [
'member' => $member->full_name,
'email' => $email,
'action' => $willCreateClient ? 'CREATE ISP CLIENT' : 'USE EXISTING ISP CLIENT',
'client_id' => $clientInfo['client_id'] ?? 'NEW',
'websites' => $matchedWebsites->pluck('domain')->implode(', '),
'sites_count' => $matchedWebsites->count(),
];
}
} catch (Exception $e) {
$this->error("Error processing {$member->full_name}: {$e->getMessage()}");
$stats['errors']++;
}
$progressBar->advance();
}
});
$progressBar->finish();
$this->newLine();
$this->info('Summary:');
$this->table(
['Metric', 'Count'],
[
['Clients created', $stats['clients_created']],
['Websites reassigned', $stats['websites_reassigned']],
['Skipped', $stats['skipped']],
['Errors', $stats['errors']],
]
);
if ($isDryRun && ! empty($dryRunResults)) {
$this->newLine();
$this->info('Dry Run Details:');
$this->table(
['Member', 'Email', 'Action', 'Client ID', 'Websites', 'Sites Count'],
collect($dryRunResults)->map(fn ($r) => [
$r['member'],
$r['email'],
$r['action'],
$r['client_id'],
$r['websites'],
$r['sites_count'],
])
);
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Extrait les domaines depuis la chaîne website_url (séparés par ;)
* et les normalise en minuscules
*/
private function extractDomains(string $websiteUrl): Collection
{
return collect(explode(';', $websiteUrl))
->map(fn ($url) => $this->normalizeDomain($url))
->filter()
->unique()
->values();
}
/**
* Normalise une URL pour extraire uniquement le nom de domaine
* Exemple: "https://www.example.com/path" → "www.example.com"
*/
private function normalizeDomain(string $url): ?string
{
$url = trim($url);
if (! str_starts_with($url, 'http')) {
$url = 'https://'.$url;
}
$host = parse_url($url, PHP_URL_HOST);
return $host ? strtolower($host) : null;
}
/**
* Crée un nouveau client dans ISPConfig avec toutes les données du membre
*
* @throws Exception
*/
private function createClient(Member $member, ISPConfigWebService $ispWeb, string $email): array
{
$username = $this->generateUsername($member);
$password = Str::random(16);
// Préparation des données client pour ISPConfig
$clientData = [
'company_name' => $member->company ?? $member->full_name,
'contact_name' => $member->full_name,
'email' => $email,
'username' => $username,
'password' => $password,
'customer_no' => (string) $member->id,
'street' => $member->address ?? '',
'zip' => $member->zipcode ?? '',
'city' => $member->city ?? '',
'country' => $member->country ?? 'FR',
'telephone' => $member->phone1 ?? '',
'mobile' => $member->phone2 ?? '',
'internet' => $member->website_url ?? '',
];
// Appel API ISPConfig pour créer le client
$clientInfo = $ispWeb->createClient($clientData);
$this->info("Created ISPConfig client for {$member->full_name} (Client ID: {$clientInfo['client_id']})");
return $clientInfo;
}
/**
* Génère un nom d'utilisateur pour ISPConfig
* Format: prenomnom (en minuscules, sans espaces ni accents)
* Exemple: "jeandurand", "johndoe"
*/
private function generateUsername(Member $member): string
{
$username = Str::slug($member->firstname.$member->lastname);
return str_replace('-', '', $username);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands;
use App\Models\Member;
use App\Services\MemberRenewalService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Console\Command\Command as CommandAlias;
class MemberRenewalProcessCommand extends Command
{
protected $signature = 'members:renewal-process
{--phase=auto : Phase à exécuter (auto|reminder_1|reminder_2|reminder_3|deactivate)}
{--year= : Année de renouvellement (par défaut : année suivante)}
{--dry-run : Mode simulation}
{email? : Email d\'un adhérent spécifique}';
protected $description = 'Processus de relances pour renouvellement adhésion';
public function __construct(
protected MemberRenewalService $renewalService
) {
parent::__construct();
}
public function handle(): int
{
$dryRun = $this->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})");
}
}
}

View File

@@ -7,11 +7,13 @@ use App\Models\IspconfigMember;
use App\Models\Member;
use App\Services\ISPConfig\ISPConfigWebService;
use Illuminate\Console\Command;
use function Laravel\Prompts\progress;
class SyncISPConfigWebMembers extends Command
{
protected $signature = 'sync:ispconfig-web-members {--refresh-cache : Vider le cache avant la synchronisation}';
protected $description = 'Synchronise les services WEB ISPConfig des membres (via member->website_url)';
/**
@@ -19,11 +21,9 @@ class SyncISPConfigWebMembers extends Command
*/
public function handle(): void
{
//@todo: Retrouver le client_id pour chaque adhérent
$this->info('Synchronisation ISPConfig WEB (via member->website_url)');
$ispWeb = new ISPConfigWebService();
$ispWeb = new ISPConfigWebService;
// Vider le cache si demandé
if ($this->option('refresh-cache')) {
@@ -67,31 +67,16 @@ class SyncISPConfigWebMembers extends Command
if ($memberDomains->isEmpty()) {
$progressBar->advance();
continue;
}
// Recherche des sites ISPConfig correspondants
$matchedWebsites = $allWebsites->filter(function ($site) use ($memberDomains, $ispWeb) {
$siteDomain = strtolower($site['domain']);
// Vérification du domaine principal
if ($memberDomains->contains($siteDomain)) {
return true;
}
// Récupération et vérification des alias (avec cache)
$aliases = $ispWeb->getWebsiteAliases($site['domain_id']);
foreach ($aliases as $alias) {
if ($memberDomains->contains(strtolower($alias))) {
return true;
}
}
return false;
});
$matchedWebsites = $ispWeb->findWebsitesForDomains($allWebsites, $memberDomains);
if ($matchedWebsites->isEmpty()) {
$progressBar->advance();
continue;
}
@@ -104,7 +89,7 @@ class SyncISPConfigWebMembers extends Command
$ispWeb
) {
$domainId = $site['domain_id'];
$sysGroupId = $site['sys_groupid'];
$sysGroupId = $site['sys_groupid'] ?? null;
$domain = $site['domain'];
// Récupération des alias (avec cache)
@@ -112,7 +97,7 @@ class SyncISPConfigWebMembers extends Command
// Filtrage des bases de données pour ce site
$databases = $allDatabases
->filter(fn($db) => $db['sys_groupid'] == $sysGroupId)
->filter(fn ($db) => $db['parent_domain_id'] == $domainId)
->map(fn ($db) => [
'database_id' => $db['database_id'],
'database_name' => $db['database_name'],
@@ -176,6 +161,8 @@ class SyncISPConfigWebMembers extends Command
return [
'domain_id' => $domainId,
'domain' => $domain,
'sys_groupid' => $sysGroupId,
'system_group' => $site['system_group'] ?? null,
'document_root' => $site['document_root'],
'active' => $site['active'],
'aliases' => $aliases,
@@ -195,6 +182,7 @@ class SyncISPConfigWebMembers extends Command
'ispconfig_service_user_id' => $siteData['domain_id'],
],
[
'ispconfig_client_id' => $siteData['sys_groupid'],
'data' => $siteData,
]
);

View File

@@ -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

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Actions;
use App\Models\Membership;
use App\Services\MemberRenewalService;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
class SendRenewalReminderAction extends Action
{
public static function make(?string $name = 'send_renewal_reminder'): static
{
return parent::make($name)
->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();
});
}
}

View File

@@ -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;

View File

@@ -23,8 +23,7 @@ class MemberGroupForm
->action(function (MemberGroup $record) {
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([

View File

@@ -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(),
])
]),
]);
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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),
])

View File

@@ -34,7 +34,7 @@ class PackageForm
Textarea::make('description')
->label(Package::getAttributeLabel('description'))
->default(null),
])
]),
]);
}

View File

@@ -31,7 +31,7 @@ class ServiceForm
TextInput::make('icon')
->label(Service::getAttributeLabel('icon'))
->default(null),
])
]),
]);
}
}

View File

@@ -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');

View File

@@ -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,

View File

@@ -45,6 +45,7 @@ class Contact extends Model
{
return __("contacts.fields.$attribute");
}
public function getFullNameAttribute(): string
{
return "{$this->firstname} {$this->lastname}";

View File

@@ -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<static>|ListmonkMember newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereListmonkUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereMemberId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ListmonkMember whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ListmonkMember extends Model

View File

@@ -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<int, \App\Models\IspconfigMember> $ispconfigs
* @property-read int|null $ispconfigs_count
* @property-read \App\Models\Membership|null $lastActiveMembership
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ListmonkMember> $listmonkMembers
* @property-read int|null $listmonk_members_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Membership> $memberships
* @property-read int|null $memberships_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\NextCloudMember> $nextcloudAccounts
* @property-read int|null $nextcloud_accounts_count
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\MemberRenewalReminder> $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<static>|Member newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereAddress($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereCity($value)
@@ -77,7 +83,8 @@ use Illuminate\Notifications\Notifiable;
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereWebsiteUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereZipcode($value)
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|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();

View File

@@ -32,7 +32,7 @@ class MemberGroup extends Model
protected $fillable = [
'name',
'description',
'identifier'
'identifier',
];
public static function getAttributeLabel(string $attribute): string

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $member_id
* @property int $renewal_year
* @property bool $reminder_1_sent
* @property bool $reminder_2_sent
* @property bool $reminder_3_sent
* @property bool $deactivation_sent
* @property \Illuminate\Support\Carbon|null $reminder_1_sent_at
* @property \Illuminate\Support\Carbon|null $reminder_2_sent_at
* @property \Illuminate\Support\Carbon|null $reminder_3_sent_at
* @property \Illuminate\Support\Carbon|null $deactivated_at
* @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<static>|MemberRenewalReminder newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereDeactivatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereDeactivationSent($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereMemberId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder1Sent($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder1SentAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder2Sent($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder2SentAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder3Sent($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereReminder3SentAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberRenewalReminder whereRenewalYear($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|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(),
]);
}
}

View File

@@ -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<static>|MemberType newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereIdentifier($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MemberType withoutTrashed()
* @mixin \Eloquent
*/
class MemberType extends Model
{
use SoftDeletes;

View File

@@ -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<int, \App\Models\Service> $services
* @property-read int|null $services_count
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership query()
@@ -52,7 +51,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereValidationDate($value)
*
* @mixin \Eloquent
*/
class Membership extends Model

View File

@@ -22,7 +22,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @method static \Illuminate\Database\Eloquent\Builder<static>|NextCloudMember whereMemberId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NextCloudMember whereNextcloudUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NextCloudMember whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NextCloudMember whereUsername($value)
* @mixin \Eloquent
*/
class NextCloudMember extends Model

View File

@@ -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<array-key, mixed>|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<static>|NotificationTemplate newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereBody($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereIdentifier($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereIsActive($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereSubject($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate whereVariables($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NotificationTemplate withoutTrashed()
* @mixin \Eloquent
*/
class NotificationTemplate extends Model
{
use HasFactory, SoftDeletes;

View File

@@ -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
@@ -42,5 +41,4 @@ class Package extends Model
{
return __('packages.fields.'.$attribute);
}
}

View File

@@ -30,7 +30,6 @@ use Spatie\Permission\Traits\HasRoles;
* @property-read int|null $permissions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Spatie\Permission\Models\Role> $roles
* @property-read int|null $roles_count
*
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
@@ -47,7 +46,6 @@ use Spatie\Permission\Traits\HasRoles;
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User withoutPermission($permissions)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User withoutRole($roles, $guard = null)
*
* @mixin \Eloquent
*/
class User extends Authenticatable implements FilamentUser

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberAccountDeactivatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
) {}
/**
* @return array<int, string>
*/
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<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberRenewalReminder2Notification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
) {}
/**
* @return array<int, string>
*/
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<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberRenewalReminder3Notification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
) {}
/**
* @return array<int, string>
*/
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<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class MemberGroupPolicy
{
return $authUser->can('Reorder:MemberGroup');
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class MemberPolicy
{
return $authUser->can('Reorder:Member');
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class MembershipPolicy
{
return $authUser->can('Reorder:Membership');
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class PackagePolicy
{
return $authUser->can('Reorder:Package');
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class RolePolicy
{
return $authUser->can('Reorder:Role');
}
}

View File

@@ -4,9 +4,9 @@ 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
{
@@ -66,5 +66,4 @@ class ServicePolicy
{
return $authUser->can('Reorder:Service');
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Policies;
use Illuminate\Foundation\Auth\User as AuthUser;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Foundation\Auth\User as AuthUser;
class UserPolicy
{
@@ -63,5 +63,4 @@ class UserPolicy
{
return $authUser->can('Reorder:User');
}
}

View File

@@ -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()
@@ -37,6 +41,7 @@ class DolibarrService
/**
* Get all members
*
* @throws ConnectionException
*/
public function getAllMembers(int $limit = 400, string $sortField = 't.rowid', string $sortOrder = 'ASC'): array
@@ -52,6 +57,7 @@ class DolibarrService
/**
* Get member subscriptions
*
* @throws ConnectionException
*/
public function getMemberSubscriptions(int|string $id): array
@@ -66,6 +72,7 @@ class DolibarrService
*
* @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
@@ -77,6 +84,4 @@ class DolibarrService
return $response->successful();
}
}

View File

@@ -18,7 +18,7 @@ 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])
);
@@ -30,7 +30,7 @@ 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])
);
@@ -105,7 +105,7 @@ class ISPConfigMailService extends ISPConfigService
// On récupère l'enregistrement COMPLET (OBLIGATOIRE)
$mailUserRecord = $this->call('mail_user_get', [
$mailuserId
$mailuserId,
]);
if (! is_array($mailUserRecord)) {
@@ -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;
}
}

View File

@@ -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;
/**
@@ -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,7 +129,7 @@ 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')
);

View File

@@ -2,6 +2,7 @@
namespace App\Services\ISPConfig;
use Exception;
use Illuminate\Support\Facades\Cache;
class ISPConfigWebService extends ISPConfigService
@@ -12,60 +13,60 @@ class ISPConfigWebService extends ISPConfigService
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllWebsites(): array
{
return Cache::remember(
"ispconfig.web.websites.all",
'ispconfig.web.websites.all',
config('services.ispconfig.cache_ttl'),
fn () => $this->call('sites_web_domain_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllDatabases(): array
{
return Cache::remember(
"ispconfig.web.databases.all",
'ispconfig.web.databases.all',
config('services.ispconfig.cache_ttl'),
fn () => $this->call('sites_database_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllFtpUsers(): array
{
return Cache::remember(
"ispconfig.web.ftp.all",
'ispconfig.web.ftp.all',
config('services.ispconfig.cache_ttl'),
fn () => $this->call('sites_ftp_user_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllShellUsers(): array
{
return Cache::remember(
"ispconfig.web.shell.all",
'ispconfig.web.shell.all',
config('services.ispconfig.cache_ttl'),
fn () => $this->call('sites_shell_user_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllDnsZones(): array
{
return Cache::remember(
"ispconfig.web.dns-zones.all",
'ispconfig.web.dns-zones.all',
config('services.ispconfig.cache_ttl'),
fn () => $this->call('dns_zone_get', ['primary_id' => -1])
);
@@ -74,9 +75,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des alias d'un site web
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteAliases(int $domainId): array
{
@@ -98,10 +97,12 @@ class ISPConfigWebService extends ISPConfigService
}
$aliases = array_map('trim', explode(',', $site['alias']));
return array_values(array_filter($aliases, fn ($alias) => ! empty($alias)));
} catch (\Exception $e) {
} catch (Exception $e) {
\Log::error("Erreur lors de la récupération des alias pour le domaine {$domainId}: ".$e->getMessage());
return [];
}
}
@@ -111,9 +112,9 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des bases de données d'un site en filtrant depuis toutes les BDD
*
* @param int $sysGroupId
* @return array
* @throws \Exception
* @todo : utiliser plutôt domainId => parent_domain_id
*
* @throws Exception
*/
public function getWebsiteDatabases(int $sysGroupId): array
{
@@ -130,7 +131,7 @@ class ISPConfigWebService extends ISPConfigService
'database_type' => $db['type'],
'active' => $db['active'],
'remote_access' => $db['remote_access'],
'remote_ips' => $db['remote_ips'] ?? ''
'remote_ips' => $db['remote_ips'] ?? '',
])
->values()
->toArray();
@@ -139,9 +140,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des utilisateurs FTP d'un site en filtrant depuis tous les comptes FTP
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteFtpUsers(int $domainId): array
{
@@ -158,7 +157,7 @@ class ISPConfigWebService extends ISPConfigService
'quota_size' => $ftp['quota_size'],
'active' => $ftp['active'],
'uid' => $ftp['uid'],
'gid' => $ftp['gid']
'gid' => $ftp['gid'],
])
->values()
->toArray();
@@ -167,9 +166,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des utilisateurs Shell d'un site en filtrant depuis tous les comptes Shell
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteShellUsers(int $domainId): array
{
@@ -189,7 +186,7 @@ class ISPConfigWebService extends ISPConfigService
'quota_size' => $shell['quota_size'],
'active' => $shell['active'],
'chroot' => $shell['chroot'],
'ssh_rsa' => !empty($shell['ssh_rsa'])
'ssh_rsa' => ! empty($shell['ssh_rsa']),
])
->values()
->toArray();
@@ -198,9 +195,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère toutes les informations complètes d'un site (alias, BDD, FTP, Shell)
*
* @param int $domainId
* @return array|null
* @throws \Exception
* @throws Exception
*/
public function getWebsiteCompleteInfo(int $domainId): ?array
{
@@ -231,7 +226,7 @@ class ISPConfigWebService extends ISPConfigService
'aliases' => $aliases,
'databases' => $this->getWebsiteDatabases($domainId, $site['sys_groupid']),
'ftp_users' => $this->getWebsiteFtpUsers($domainId),
'shell_users' => $this->getWebsiteShellUsers($domainId)
'shell_users' => $this->getWebsiteShellUsers($domainId),
];
}
);
@@ -239,9 +234,6 @@ class ISPConfigWebService extends ISPConfigService
/**
* Vide le cache pour un domaine spécifique
*
* @param int $domainId
* @return void
*/
public function clearDomainCache(int $domainId): void
{
@@ -251,16 +243,177 @@ class ISPConfigWebService extends ISPConfigService
/**
* Vide tout le cache ISPConfig Web
*
* @return void
*/
public function clearAllCache(): void
{
Cache::forget("ispconfig.web.websites.all");
Cache::forget("ispconfig.web.databases.all");
Cache::forget("ispconfig.web.ftp.all");
Cache::forget("ispconfig.web.shell.all");
Cache::forget("ispconfig.web.dns-zones.all");
Cache::forget("ispconfig.web.domain-alias.all");
Cache::forget('ispconfig.web.websites.all');
Cache::forget('ispconfig.web.databases.all');
Cache::forget('ispconfig.web.ftp.all');
Cache::forget('ispconfig.web.shell.all');
Cache::forget('ispconfig.web.dns-zones.all');
Cache::forget('ispconfig.web.domain-alias.all');
}
/**
* Find websites matching given domains (including aliases)
*/
public function findWebsitesForDomains(\Illuminate\Support\Collection $allWebsites, \Illuminate\Support\Collection $domains): \Illuminate\Support\Collection
{
return $allWebsites->filter(function ($site) use ($domains) {
$siteDomain = strtolower($site['domain']);
if ($domains->contains($siteDomain)) {
return true;
}
$aliases = $this->getWebsiteAliases($site['domain_id']);
foreach ($aliases as $alias) {
if ($domains->contains(strtolower($alias))) {
return true;
}
}
return false;
});
}
/**
* Find a client by email using sys_groupid from existing websites
* Since ISPConfig API doesn't provide a direct way to search clients by email,
* we search through websites to find the sys_groupid (client group)
*
* @throws Exception
*/
public function findClientByEmail(string $email): ?array
{
$allWebsites = $this->getAllWebsites();
$normalizedEmail = strtolower(trim($email));
foreach ($allWebsites as $website) {
$sysGroupId = $website['sys_groupid'] ?? null;
$systemGroup = $website['system_group'] ?? null;
if (! $sysGroupId || ! $systemGroup) {
continue;
}
try {
$clientData = $this->call('client_get_by_groupid', [$sysGroupId]);
if (! empty($clientData) && isset($clientData['email'])) {
if (strtolower(trim($clientData['email'])) === $normalizedEmail) {
return [
'client_id' => $clientData['client_id'] ?? $sysGroupId,
'groupid' => $sysGroupId,
'system_group' => $systemGroup,
'email' => $clientData['email'],
];
}
}
} catch (Exception $e) {
continue;
}
}
return null;
}
/**
* Create a new ISPConfig client
* Returns an array with client_id and groupid
*
* @throws Exception
*/
public function createClient(array $clientData): array
{
$resellerId = 1;
$defaultParams = [
'limit_maildomain' => -1,
'limit_mailbox' => -1,
'limit_mailalias' => -1,
'limit_mailaliasdomain' => -1,
'limit_mailforward' => -1,
'limit_mailcatchall' => -1,
'limit_mailrouting' => 0,
'limit_mail_wblist' => 0,
'limit_mailfilter' => -1,
'limit_fetchmail' => -1,
'limit_mailquota' => -1,
'limit_spamfilter_wblist' => 0,
'limit_spamfilter_user' => 0,
'limit_spamfilter_policy' => 1,
'default_webserver' => 1,
'limit_web_ip' => '',
'limit_web_domain' => -1,
'limit_web_quota' => -1,
'web_php_options' => 'no,fast-cgi,cgi,mod,suphp',
'limit_web_subdomain' => -1,
'limit_web_aliasdomain' => -1,
'limit_ftp_user' => -1,
'limit_shell_user' => 0,
'ssh_chroot' => 'no,jailkit,ssh-chroot',
'limit_webdav_user' => 0,
'default_dnsserver' => 1,
'limit_dns_zone' => -1,
'limit_dns_slave_zone' => -1,
'limit_dns_record' => -1,
'default_dbserver' => 1,
'limit_database' => -1,
'limit_cron' => 0,
'limit_cron_type' => 'url',
'limit_cron_frequency' => 5,
'limit_traffic_quota' => -1,
'limit_client' => 0,
'parent_client_id' => 0,
'language' => 'fr',
'usertheme' => 'default',
'template_master' => 0,
'template_additional' => '',
'created_at' => 0,
'default_mailserver' => 1,
];
$params = array_merge($defaultParams, $clientData);
$clientId = $this->call('client_add', [$resellerId, $params]);
$client = $this->call('client_get', [(int) $clientId]);
$this->clearAllCache();
return [
'client_id' => (int) $clientId,
'groupid' => (int) ($client['groupid'] ?? $clientId),
'system_group' => 'client'.$clientId,
];
}
/**
* Update website to assign it to a different client
*
* @throws Exception
*/
public function updateWebsiteClient(int $domainId, int $newClientId): bool
{
$websiteRecord = $this->call('sites_web_domain_get', ['domain_id' => $domainId]);
if (! is_array($websiteRecord) || empty($websiteRecord)) {
throw new \RuntimeException("Website with domain_id {$domainId} not found");
}
// sys_groupid n'est pas trouvable dans les données client
// $websiteRecord['sys_groupid'] = $newClientId;
$websiteRecord['system_group'] = 'client'.$newClientId;
$result = $this->call('sites_web_domain_update', [
0,
$domainId,
$websiteRecord,
]);
$this->clearAllCache();
return (bool) $result;
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Services;
use App\Models\Member;
use App\Models\MemberRenewalReminder;
use App\Notifications\MemberAccountDeactivatedNotification;
use App\Notifications\MemberRenewalReminder2Notification;
use App\Notifications\MemberRenewalReminder3Notification;
use App\Notifications\MemberRenewalReminderNotification;
use App\Services\Dolibarr\DolibarrService;
class MemberRenewalService
{
public function __construct(
protected DolibarrService $dolibarr,
protected MemberService $memberService
) {}
public function sendReminder1(Member $member, int $year): bool
{
$reminder = MemberRenewalReminder::firstOrCreate(
['member_id' => $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);
}
}

View File

@@ -27,6 +27,7 @@ class NextcloudService
/**
* Disable user by email
*
* @throws ConnectionException
*/
public function disableUserByEmail(string $email): void
@@ -34,7 +35,8 @@ class NextcloudService
$userId = $this->findUserIdByEmail($email);
if (! $userId) {
Log::warning("Utilisateur Nextcloud introuvable", ['email' => $email]);
Log::warning('Utilisateur Nextcloud introuvable', ['email' => $email]);
return;
}
@@ -47,6 +49,7 @@ class NextcloudService
/**
* Desable user by id
*
* @throws ConnectionException
*/
public function disableUserById(string $userId): void
@@ -110,6 +113,4 @@ class NextcloudService
->get("/cloud/users/{$userId}")
->json('ocs.data') ?? [];
}
}

BIN
assets/LRL-roxane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -29,7 +29,7 @@ return new class extends Migration
'valid',
'pending',
'cancelled',
'excluded'
'excluded',
])->default('draft');
// Nature

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('member_renewal_reminders', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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' => '<p>Bonjour {member_name},</p>'
.'<p>Nous vous contactons au sujet de votre adhésion au sein de notre association.</p>'
.'<p>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.</p>'
.'<p>Pour toute question ou besoin d\'assistance, n\'hésitez pas à nous contacter.</p>'
.'<p>L\'année touche à sa fin et nous vous invitons à renouveler votre adhésion pour l\'année prochaine.</p>'
.'<p>Cela vous permettra de continuer à bénéficier de l\'ensemble de nos services sans interruption.</p>'
.'<p>Nous restons à votre disposition pour toute question.</p>'
.'<p>Cordialement,<br>L\'équipe {app_name}</p>',
'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' => '<p>Bonjour {member_name},</p>'
.'<p>Nous vous avons récemment contacté concernant le renouvellement de votre adhésion pour l\'année prochaine.</p>'
.'<p>Si vous n\'avez pas encore renouvelé, nous vous encourageons à le faire rapidement pour garantir la continuité de vos services.</p>'
.'<p>N\'hésitez pas à nous contacter si vous avez des questions.</p>'
.'<p>Cordialement,<br>L\'équipe {app_name}</p>',
'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' => '<p>Bonjour {member_name},</p>'
.'<p>Nous vous contactons une dernière fois concernant le renouvellement de votre adhésion.</p>'
.'<p><strong>Sans action de votre part d\'ici la fin janvier, votre compte et vos services seront automatiquement désactivés.</strong></p>'
.'<p>Pour éviter toute interruption, nous vous invitons à renouveler dès maintenant.</p>'
.'<p>Pour toute question, n\'hésitez pas à nous contacter.</p>'
.'<p>Cordialement,<br>L\'équipe {app_name}</p>',
'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' => '<p>Bonjour {member_name},</p>'
.'<p>Faute de renouvellement de votre adhésion, votre compte et vos services ont été désactivés.</p>'
.'<p>Si vous souhaitez réactiver votre compte, n\'hésitez pas à nous contacter.</p>'
.'<p>Nous vous remercions pour la confiance que vous nous avez accordée.</p>'
.'<p>Cordialement,<br>L\'équipe {app_name}</p>',
'variables' => [
'member_name' => 'Nom complet du membre',

View File

@@ -1,4 +1,5 @@
<?php
namespace Deployer;
require 'recipe/laravel.php';

View File

@@ -8,5 +8,5 @@ return [
'responses' => [
'success' => 'Your message has been sent successfully',
'error' => 'An error occurred while sending the message',
]
],
];

View File

@@ -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',
],
];

View File

@@ -8,5 +8,5 @@ return [
'name' => 'Name',
'description' => 'Description',
'is_active' => 'Active',
]
],
];

View File

@@ -9,5 +9,5 @@ return [
'description' => 'Description',
'url' => 'URL',
'icon' => 'Icon',
]
],
];

View File

@@ -8,5 +8,5 @@ return [
'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',
]
],
];

View File

@@ -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',
],
];

View File

@@ -8,5 +8,5 @@ return [
'name' => 'Nom',
'description' => 'Description',
'is_active' => 'Actif',
]
],
];

0
memberships Normal file
View File

View File

@@ -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');

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Commands;
use App\Enums\IspconfigType;
use App\Models\IspconfigMember;
use App\Models\Member;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateISPWebAccountsTest extends TestCase
{
use RefreshDatabase;
public function test_command_exits_successfully_when_no_members_with_websites(): void
{
$this->artisan('ext:create-isp-accounts')
->expectsOutput('No members with websites found')
->assertExitCode(0);
}
public function test_command_processes_members_regardless_of_status(): void
{
Member::factory()->create([
'status' => 'draft',
'website_url' => 'example.com',
'firstname' => 'John',
'lastname' => 'Doe',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->expectsOutput('DRY RUN MODE - No changes will be made')
->assertExitCode(0);
}
public function test_command_runs_in_dry_run_mode(): void
{
Member::factory()->create([
'status' => 'valid',
'website_url' => 'example.com',
'firstname' => 'John',
'lastname' => 'Doe',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->expectsOutput('DRY RUN MODE - No changes will be made')
->assertExitCode(0);
}
public function test_command_skips_member_with_existing_client(): void
{
$member = Member::factory()->create([
'status' => 'valid',
'website_url' => 'example.com',
]);
IspconfigMember::create([
'member_id' => $member->id,
'type' => IspconfigType::WEB,
'ispconfig_client_id' => '123',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->assertExitCode(0);
$this->assertDatabaseCount('ispconfigs_members', 1);
}
}