Publication initiale de RelaisSMS : documentation et sources nettoyees

This commit is contained in:
2026-10-21 09:29:39 +02:00
commit ac186b03b1
158 changed files with 24818 additions and 0 deletions
@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\SourceSyncRun;
use App\Services\Calendar\CalendarEventSyncService;
use App\Services\Calendar\CalendarReminderSender;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable;
class ProcessCalendarRemindersCommand extends Command
{
protected $signature = 'calendar:process-reminders
{--no-sync : Ne synchronise pas l agenda avant traitement}
{--force-sync : Force la synchronisation CalDAV avant traitement}';
protected $description = 'Synchronise l agenda CalDAV et envoie les rappels SMS dus.';
public function handle(CalendarEventSyncService $syncService, CalendarReminderSender $sender): int
{
if ($this->shouldSync()) {
try {
$run = $syncService->sync();
$this->line((string) $run->message);
} catch (Throwable $exception) {
$this->error('Synchronisation CalDAV agenda en echec : '.$exception->getMessage());
return self::FAILURE;
}
} elseif (! $this->option('no-sync')) {
$interval = (int) config('relais_sms.caldav.sync_interval_minutes', 60);
$this->line('Synchronisation CalDAV ignoree : derniere synchronisation recente, intervalle '.$interval.' minute(s).');
}
$sent = $sender->sendDue();
$this->info($sent.' rappel(s) SMS envoye(s).');
return self::SUCCESS;
}
private function shouldSync(): bool
{
if ($this->option('no-sync')) {
return false;
}
if ($this->option('force-sync')) {
return true;
}
$interval = max(1, (int) config('relais_sms.caldav.sync_interval_minutes', 60));
$lastRun = SourceSyncRun::query()
->where('source', 'caldav_calendar')
->latest('started_at')
->first();
$rawStartedAt = $lastRun?->getRawOriginal('started_at');
if ($rawStartedAt === null) {
return true;
}
$lastStartedAt = Carbon::parse((string) $rawStartedAt, 'UTC');
$nextAllowedSyncAt = Carbon::now()->utc()->subMinutes($interval);
return $lastStartedAt->lessThanOrEqualTo($nextAllowedSyncAt);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\SmsCampaign;
use Illuminate\Console\Command;
class PruneSmsHistoryCommand extends Command
{
protected $signature = 'sms-campaigns:prune-history {--dry-run : Compte les campagnes expirees sans les supprimer.}';
protected $description = 'Supprime les campagnes SMS plus anciennes que la duree de retention configuree.';
public function handle(): int
{
$retentionYears = (int) config('relais_sms.default_settings.history_retention_years', 3);
if ($retentionYears < 1) {
$this->error('La retention doit etre superieure ou egale a 1 an.');
return self::FAILURE;
}
$expiresBefore = now()->subYears($retentionYears);
$query = SmsCampaign::query()->where('created_at', '<', $expiresBefore);
$count = $query->count();
if ($this->option('dry-run')) {
$this->line($count.' campagne(s) SMS expiree(s) avant '.$expiresBefore->format('Y-m-d H:i:s').'.');
return self::SUCCESS;
}
$query->delete();
$this->line($count.' campagne(s) SMS supprimee(s).');
return self::SUCCESS;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Models\SmsCampaign;
use App\Services\Sms\SmsCampaignReconciler;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class ReconcileSmsCampaignsCommand extends Command
{
protected $signature = 'sms-campaigns:reconcile {--limit=50 : Nombre maximum de campagnes a verifier.}';
protected $description = 'Verifie les envois incertains dans l historique OVH sans renvoyer de SMS.';
public function handle(SmsCampaignReconciler $reconciler): int
{
$processed = 0;
SmsCampaign::query()
->where('provider', 'ovh')
->where(function (Builder $query): void {
$query->where('status', 'uncertain')
->orWhere(fn (Builder $query): Builder => $query->where('status', 'sending')->where('updated_at', '<', now()->subMinutes(10)));
})
->orderBy('updated_at')
->limit(max(1, (int) $this->option('limit')))
->get()
->each(function (SmsCampaign $campaign) use ($reconciler, &$processed): void {
if ($campaign->status === 'sending') {
$campaign->update([
'status' => 'uncertain',
'error_message' => 'Envoi interrompu avant confirmation. Verification OVH necessaire ; ne pas renvoyer cette campagne.',
]);
$campaign->deliveries()->where('status', 'queued')->update(['status' => 'uncertain']);
}
$reconciler->reconcile($campaign);
$processed++;
});
$this->line($processed.' campagne(s) verifiee(s), sans nouvel envoi.');
return self::SUCCESS;
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\SmsCampaign;
use App\Services\Sms\SmsCampaignSender;
use Illuminate\Console\Command;
class SendDueSmsCampaignsCommand extends Command
{
protected $signature = 'sms-campaigns:send-due {--limit=50 : Nombre maximum de campagnes a prendre en charge.}';
protected $description = 'Envoie les campagnes SMS programmees arrivees a echeance.';
public function handle(SmsCampaignSender $sender): int
{
$limit = max(1, (int) $this->option('limit'));
$processed = 0;
SmsCampaign::query()
->where('status', 'scheduled')
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', now())
->orderBy('scheduled_at')
->limit($limit)
->get()
->each(function (SmsCampaign $campaign) use ($sender, &$processed): void {
if ($sender->sendScheduled($campaign) !== null) {
$processed++;
}
});
$this->line($processed.' campagne(s) SMS programmee(s) prise(s) en charge.');
return self::SUCCESS;
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands;
use App\Contracts\PhoneNumberNormalizerInterface;
use App\Models\Contact;
use App\Models\ContactPhoneNumber;
use App\Models\User;
use App\Services\Sms\SmsCampaignSender;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class SendTestSmsCommand extends Command
{
protected $signature = 'sms:send-test {phone} {--message=Test RelaisSMS}';
protected $description = 'Cree le contact de test si besoin et envoie un SMS uniquement a ce numero.';
public function handle(PhoneNumberNormalizerInterface $normalizer, SmsCampaignSender $sender): int
{
$phone = (string) $this->argument('phone');
$message = trim((string) $this->option('message'));
$normalized = $normalizer->normalize($phone, (string) config('relais_sms.default_settings.default_country', 'FR'));
if (! $normalized->isValid || ! $normalized->isMobile || $normalized->normalizedNumber === null) {
$this->error($normalized->error ?: 'Numero invalide ou non mobile.');
return self::FAILURE;
}
if ($message === '') {
$this->error('Message vide.');
return self::FAILURE;
}
[$contact, $phoneNumber] = DB::transaction(function () use ($normalized): array {
$phoneNumber = ContactPhoneNumber::query()
->where('normalized_number', $normalized->normalizedNumber)
->with('contact')
->first();
if ($phoneNumber !== null && $phoneNumber->contact !== null) {
return [$phoneNumber->contact, $phoneNumber];
}
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Test',
'last_name' => 'RelaisSMS',
'notes' => 'Contact cree pour test envoi SMS.',
]);
$phoneNumber = $contact->phoneNumbers()->create([
'label' => 'Test',
'raw_number' => $normalized->rawNumber,
'normalized_number' => $normalized->normalizedNumber,
'country' => $normalized->country,
'is_valid' => $normalized->isValid,
'is_mobile' => $normalized->isMobile,
'is_primary' => true,
'validation_error' => $normalized->error,
]);
return [$contact, $phoneNumber];
});
$user = User::query()->first();
if ($user === null) {
$this->error('Aucun utilisateur applicatif disponible.');
return self::FAILURE;
}
$campaign = $sender->send($user, $message, [[
'contact_id' => $contact->id,
'contact_name' => $contact->displayName(),
'phone_number_id' => $phoneNumber->id,
'number' => $normalized->normalizedNumber,
'raw_number' => $normalized->rawNumber,
'label' => $phoneNumber->label,
'selection' => ['Test CLI'],
]]);
$this->line('Campagne #'.$campaign->id.' : '.$campaign->status);
if ($campaign->error_message !== null) {
$this->error($campaign->error_message);
return self::FAILURE;
}
return $campaign->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Services\Calendar\CalendarEventSyncService;
use Illuminate\Console\Command;
use Throwable;
class SyncCalDavCalendarCommand extends Command
{
protected $signature = 'calendar:sync-caldav';
protected $description = 'Synchronise les evenements depuis l agenda CalDAV configure.';
public function handle(CalendarEventSyncService $syncService): int
{
try {
$run = $syncService->sync();
} catch (Throwable $exception) {
$this->error('Synchronisation CalDAV agenda en echec : '.$exception->getMessage());
return self::FAILURE;
}
$this->info((string) $run->message);
$this->line('Objets lus : '.$run->fetched_contacts);
$this->line('Evenements synchronises : '.$run->synced_contacts);
$this->line('Evenements annules localement : '.$run->deleted_contacts);
return self::SUCCESS;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Services\CardDav\CardDavContactSyncService;
use Illuminate\Console\Command;
use Throwable;
class SyncCardDavContactsCommand extends Command
{
protected $signature = 'contacts:sync-carddav';
protected $description = 'Synchronise les contacts depuis le carnet CardDAV configure.';
public function handle(CardDavContactSyncService $syncService): int
{
try {
$run = $syncService->sync();
} catch (Throwable $exception) {
$this->error('Synchronisation CardDAV en echec : '.$exception->getMessage());
return self::FAILURE;
}
$this->info((string) $run->message);
$this->line('Contacts lus : '.$run->fetched_contacts);
$this->line('Contacts synchronises : '.$run->synced_contacts);
$this->line('Contacts supprimes localement : '.$run->deleted_contacts);
return self::SUCCESS;
}
}