'Brouillon', 'scheduled' => 'Programme', 'sending' => 'Envoi', 'uncertain' => 'A verifier', 'sent' => 'Envoye', 'partial' => 'Partiel', 'failed' => 'Echec', 'canceled' => 'Annule', ]; public function index(Request $request): View { $filters = $this->validateFilters($request); return view('sms-campaigns.index', [ 'campaigns' => $this->filteredCampaignsQuery($filters) ->latest() ->paginate(20) ->withQueryString(), 'filters' => $filters, 'groups' => ContactGroup::query()->orderBy('name')->get(['id', 'name']), 'statuses' => self::STATUSES, 'users' => User::query()->orderBy('name')->get(['id', 'name']), ]); } public function show(SmsCampaign $smsCampaign): View { return view('sms-campaigns.show', [ 'campaign' => $smsCampaign->load(['deliveries' => fn ($query) => $query->orderBy('id'), 'user']), ]); } public function export(Request $request): StreamedResponse { $filters = $this->validateFilters($request); $query = $this->filteredCampaignsQuery($filters) ->with(['deliveries' => fn ($query) => $query->orderBy('id')]) ->latest(); return response()->streamDownload(function () use ($query): void { $csv = fopen('php://output', 'w'); fputcsv($csv, [ 'campagne_id', 'date_creation', 'date_programmee', 'date_envoi', 'utilisateur', 'statut_campagne', 'groupes_selectionnes', 'message', 'destinataires_prevus', 'destinataires_envoyes', 'destinataires_echoues', 'contact', 'numero', 'statut_destinataire', 'message_fournisseur_id', 'erreur', ], ';'); $query->chunk(100, function ($campaigns) use ($csv): void { foreach ($campaigns as $campaign) { foreach ($campaign->deliveries as $delivery) { fputcsv($csv, [ $campaign->id, $campaign->created_at->format('Y-m-d H:i:s'), $campaign->scheduled_at?->format('Y-m-d H:i:s'), $campaign->sent_at?->format('Y-m-d H:i:s'), $campaign->user?->name ?: 'Utilisateur supprime', self::STATUSES[$campaign->status] ?? $campaign->status, implode(', ', $campaign->selected_group_names ?? []), $campaign->message, $campaign->recipients_count, $campaign->sent_count, $campaign->failed_count, $delivery->contact_name, $delivery->number, $delivery->status, $delivery->provider_message_id, $delivery->error_message, ], ';'); } } }); }, 'historique-sms-'.now()->format('Ymd-His').'.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function store( Request $request, RecipientResolver $resolver, SmsCampaignSender $sender, SmsMessageComposer $messageComposer, ): RedirectResponse { $validated = $request->validate([ 'message' => ['required', 'string', 'min:1', 'max:612'], 'include_sms_header' => ['nullable', 'boolean'], 'include_sms_footer' => ['nullable', 'boolean'], 'contact_ids' => ['array'], 'contact_ids.*' => ['integer', Rule::exists('contacts', 'id')->whereNull('deleted_at')], 'group_ids' => ['array'], 'group_ids.*' => ['integer', Rule::exists('contact_groups', 'id')->whereNull('deleted_at')], 'send_mode' => ['nullable', Rule::in(['now', 'scheduled'])], 'scheduled_at' => ['nullable', 'date'], ]); $contactIds = $validated['contact_ids'] ?? []; $groupIds = $validated['group_ids'] ?? []; $resolution = $resolver->resolve($contactIds, $groupIds); $recipients = $resolution->recipients; $maxRecipients = (int) config('relais_sms.default_settings.max_sms_per_campaign', 100); $messageSettings = $messageComposer->settings(); $message = $messageComposer->compose( (string) $validated['message'], $request->has('include_sms_header') ? $request->boolean('include_sms_header') : $messageSettings['header'] !== '', $request->has('include_sms_footer') ? $request->boolean('include_sms_footer') : $messageSettings['footer'] !== '', ); if ($recipients === []) { return redirect() ->route('recipients.index') ->withErrors(['selection' => 'Aucun numero valide a envoyer.']) ->withInput(); } if (mb_strlen($message) > 612) { return redirect() ->route('recipients.index') ->withErrors(['message' => 'Le message complet avec entete et pied depasse 612 caracteres.']) ->withInput(); } if (count($recipients) > $maxRecipients) { return redirect() ->route('recipients.index') ->withErrors(['selection' => 'La selection depasse la limite de '.$maxRecipients.' SMS.']) ->withInput(); } if (($validated['send_mode'] ?? 'now') === 'scheduled') { if (! (bool) config('relais_sms.default_settings.scheduled_sends_enabled', true)) { return redirect() ->route('recipients.index') ->withErrors(['scheduled_at' => 'Les envois programmes sont desactives.']) ->withInput(); } if (($validated['scheduled_at'] ?? null) === null) { return redirect() ->route('recipients.index') ->withErrors(['scheduled_at' => 'La date et l heure d envoi sont obligatoires pour programmer un SMS.']) ->withInput(); } $scheduledAt = Carbon::parse((string) $validated['scheduled_at'], $this->scheduledTimezone())->utc(); if ($scheduledAt->lte(now())) { return redirect() ->route('recipients.index') ->withErrors(['scheduled_at' => 'La date et l heure d envoi doivent etre futures.']) ->withInput(); } $campaign = $sender->schedule($request->user(), $message, $recipients, $contactIds, $groupIds, $scheduledAt); return redirect() ->route('sms-campaigns.show', $campaign) ->with('status', 'Campagne programmee pour le '.$campaign->scheduled_at?->timezone($this->scheduledTimezone())->format('d/m/Y H:i').'.'); } $campaign = $sender->send($request->user(), $message, $recipients, $contactIds, $groupIds); if ($campaign->status === 'uncertain') { return redirect()->route('sms-campaigns.show', $campaign)->with('status', $campaign->error_message); } return redirect() ->route('sms-campaigns.show', $campaign) ->with( $campaign->status === 'failed' ? 'error' : 'status', $campaign->status === 'failed' ? 'Envoi refuse ou echoue : '.$campaign->error_message : 'Campagne envoyee a '.$campaign->sent_count.' destinataire(s).', ); } public function cancel(SmsCampaign $smsCampaign): RedirectResponse { $canceled = DB::transaction(function () use ($smsCampaign): bool { $campaign = SmsCampaign::query() ->whereKey($smsCampaign->id) ->lockForUpdate() ->first(); if (! $campaign || $campaign->status !== 'scheduled') { return false; } $campaign->update([ 'status' => 'canceled', 'canceled_at' => now(), ]); $campaign->deliveries() ->where('status', 'queued') ->update(['status' => 'canceled']); return true; }); return redirect() ->route('sms-campaigns.show', $smsCampaign) ->with( $canceled ? 'status' : 'error', $canceled ? 'Campagne programmee annulee.' : 'Cette campagne ne peut plus etre annulee.', ); } /** * @return array{from?: string|null, to?: string|null, status?: string|null, user_id?: string|null, group_id?: string|null} */ private function validateFilters(Request $request): array { return $request->validate([ 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date'], 'status' => ['nullable', Rule::in(array_keys(self::STATUSES))], 'user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'group_id' => ['nullable', 'integer', Rule::exists('contact_groups', 'id')->whereNull('deleted_at')], ]); } /** * @param array{from?: string|null, to?: string|null, status?: string|null, user_id?: string|null, group_id?: string|null} $filters * @return Builder */ private function filteredCampaignsQuery(array $filters): Builder { return SmsCampaign::query() ->with('user') ->when($filters['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)) ->when($filters['to'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date)) ->when($filters['status'] ?? null, fn (Builder $query, string $status): Builder => $query->where('status', $status)) ->when($filters['user_id'] ?? null, fn (Builder $query, string $userId): Builder => $query->where('user_id', (int) $userId)) ->when($filters['group_id'] ?? null, function (Builder $query, string $groupId): Builder { $groupId = (int) $groupId; return $query->where(function (Builder $query) use ($groupId): void { $query ->whereJsonContains('selected_group_ids', $groupId) ->orWhereHas('deliveries.contact.groups', fn (Builder $query): Builder => $query->where('contact_groups.id', $groupId)); }); }); } private function scheduledTimezone(): string { return (string) config('relais_sms.default_settings.display_timezone', 'Europe/Paris'); } }