public function __construct(private readonly ImageLibrary $library) {} public function index(CampaignSelector $selector): View { $tenant = $this->tenantId(); $rank = array_flip(array_keys(Campaign::STATUSES)); $campaigns = Campaign::where('tenant_id', $tenant)->with('groups:id,display_name')->get() ->sortBy(fn (Campaign $c) => [ $rank[$c->status()], match ($c->status()) { 'running' => -$c->priority, 'ended' => -$c->ends_at->timestamp, default => $c->starts_at->timestamp, }, $c->id, ])->values(); return view('admin.campaigns.index', [ 'campaigns' => $campaigns, 'reach' => $this->reach($campaigns->filter(fn (Campaign $c) => $c->status() === 'running'), $selector), 'missing' => $campaigns->reject(fn (Campaign $c) => $this->library->exists($c->image_path))->pluck('id')->all(), // Templates publiés qui n'affichent aucune bannière. 'withoutBanner' => Template::where('tenant_id', $tenant)->whereNotNull('html_full') ->where('html_full', 'not like', '%banniere%')->orderBy('name')->pluck('name'), ]); } public function create(): View { $start = now(config('signature.timezone'))->startOfDay(); return $this->form(new Campaign([ 'priority' => 10, 'active' => false, 'starts_at' => $this->toStorage($start), 'ends_at' => $this->toStorage($start->copy()->addDays(14)->setTime(23, 59)), ])); } public function store(Request $request): RedirectResponse { [$attributes, $groups] = $this->validated($request); $campaign = Campaign::create($attributes + [ 'tenant_id' => $this->tenantId(), 'active' => $this->isAdmin() && $request->boolean('active'), ]); $campaign->groups()->sync($groups); AuditLog::record('admin', $this->actor(), 'campaign.create', $campaign, $campaign->only(['name', 'image_path', 'active']) + ['groups' => $groups]); return redirect()->route('admin.campaigns.index')->with('status', $campaign->active ? "Campagne « {$campaign->name} » créée et active." : "Campagne « {$campaign->name} » créée. Elle reste inactive tant qu'un administrateur ne l'active pas."); } public function edit(Campaign $campaign, SignatureBuilder $builder): View { $this->ensureTenant($campaign); return $this->form($campaign, $builder->bannerHtml($campaign)); } public function update(Request $request, Campaign $campaign): RedirectResponse { $this->ensureTenant($campaign); abort_if($this->locked($campaign), 403, 'Campagne active : seul un administrateur peut la modifier.'); [$attributes, $groups] = $this->validated($request); if ($this->isAdmin()) { $attributes['active'] = $request->boolean('active'); } $campaign->fill($attributes); $changes = $campaign->getDirty(); $campaign->save(); $synced = $campaign->groups()->sync($groups); if ($synced['attached'] || $synced['detached']) { $changes['groups'] = $groups; } if ($changes) { AuditLog::record('admin', $this->actor(), 'campaign.update', $campaign, $changes); } return redirect()->route('admin.campaigns.index')->with('status', "Campagne « {$campaign->name} » enregistrée."); } /** Active ou désactive la campagne : effet dès le prochain message rédigé. */ public function toggle(Campaign $campaign): RedirectResponse { $this->ensureTenant($campaign); $campaign->update(['active' => ! $campaign->active]); AuditLog::record('admin', $this->actor(), $campaign->active ? 'campaign.activate' : 'campaign.deactivate', $campaign); $message = ! $campaign->active ? 'désactivée : la bannière n\'est plus insérée.' : match ($campaign->status()) { 'scheduled' => 'activée : elle démarrera le '.$campaign->starts_at->timezone(config('signature.timezone'))->format('d/m/Y à H:i').'.', 'ended' => 'activée, mais sa période est terminée : modifiez les dates pour la relancer.', default => 'activée : la bannière apparaît dès le prochain message rédigé.', }; return back()->with('status', "Campagne « {$campaign->name} » {$message}"); } public function destroy(Campaign $campaign): RedirectResponse { $this->ensureTenant($campaign); AuditLog::record('admin', $this->actor(), 'campaign.delete', $campaign, $campaign->only(['name', 'image_path'])); $campaign->delete(); return back()->with('status', "Campagne « {$campaign->name} » supprimée. L'image reste dans la bibliothèque."); } private function form(Campaign $campaign, string $bannerHtml = ''): View { return view('admin.campaigns.form', [ 'campaign' => $campaign, 'bannerHtml' => $bannerHtml, 'locked' => $campaign->exists && $this->locked($campaign), // Largeur laissée vide si c'est la valeur automatique : elle suivra l'image en cas de changement. 'width' => $campaign->exists && $campaign->width !== $this->autoWidth((string) $campaign->image_path) ? $campaign->width : null, 'selected' => $campaign->exists ? $campaign->groups()->pluck('directory_groups.id')->all() : [], 'images' => $this->library->all(), // Tous les groupes synchronisés, sauf les groupes de rôles du back-office. 'groups' => DirectoryGroup::where('tenant_id', $this->tenantId()) ->whereNotIn('display_name', array_values(config('signature.roles'))) ->withCount('members')->orderBy('display_name')->get(), ]); } /** @return array{0: array, 1: array} attributs de la campagne, groupes ciblés */ private function validated(Request $request): array { $data = $request->validate([ 'name' => ['required', 'string', 'max:120'], 'image_path' => ['nullable', 'required_without:image_file', 'string', 'max:150'], 'image_file' => ['nullable', ...ImageLibrary::uploadRules()], 'alt_text' => ['required', 'string', 'max:150'], 'link_url' => ['nullable', 'url:http,https', 'max:255'], 'width' => ['nullable', 'integer', 'between:100,800'], 'starts_at' => ['required', 'date_format:'.self::DATE_FORMAT], 'ends_at' => ['required', 'date_format:'.self::DATE_FORMAT, 'after:starts_at', 'before:2038-01-01T00:00'], 'priority' => ['required', 'integer', 'between:0,1000'], 'groups' => ['nullable', 'array'], 'groups.*' => ['integer', Rule::exists('directory_groups', 'id')->where('tenant_id', $this->tenantId())], ], ImageLibrary::uploadMessages('image_file') + [ 'name.required' => 'Donnez un nom à la campagne.', 'name.max' => 'Le nom est limité à 120 caractères.', 'alt_text.max' => 'Le texte alternatif est limité à 150 caractères.', 'link_url.max' => 'Le lien est limité à 255 caractères.', 'width.integer' => 'La largeur doit être comprise entre 100 et 800 px.', 'ends_at.before' => 'La date de fin est trop lointaine.', 'image_path.required_without' => 'Choisissez une image dans la bibliothèque ou envoyez-en une.', 'alt_text.required' => 'Le texte alternatif est obligatoire : il remplace la bannière quand les images sont bloquées.', 'link_url.url' => 'Le lien doit être une adresse web complète (https://…).', 'width.between' => 'La largeur doit être comprise entre 100 et 800 px.', 'starts_at.required' => 'Date de début invalide.', 'starts_at.date_format' => 'Date de début invalide.', 'ends_at.required' => 'Date de fin invalide.', 'ends_at.date_format' => 'Date de fin invalide.', 'ends_at.after' => 'La fin doit être postérieure au début.', 'priority.required' => 'La priorité doit être un nombre entre 0 et 1000.', 'priority.integer' => 'La priorité doit être un nombre entre 0 et 1000.', 'priority.between' => 'La priorité doit être un nombre entre 0 et 1000.', 'groups.*.exists' => 'Groupe inconnu.', ]); if ($request->hasFile('image_file')) { $image = $this->library->store($request->file('image_file')); AuditLog::record('admin', $this->actor(), 'image.upload', null, ['file' => $image], $this->tenantId()); } else { $image = (string) $data['image_path']; if (! $this->library->exists($image)) { throw ValidationException::withMessages(['image_path' => 'Image introuvable dans la bibliothèque.']); } } $timezone = config('signature.timezone'); return [ [ 'name' => $data['name'], 'image_path' => $image, 'alt_text' => $data['alt_text'], 'link_url' => $data['link_url'] ?? null, 'width' => $data['width'] ?? $this->autoWidth($image), 'starts_at' => $this->toStorage(Carbon::createFromFormat(self::DATE_FORMAT, $data['starts_at'], $timezone)->startOfMinute()), 'ends_at' => $this->toStorage(Carbon::createFromFormat(self::DATE_FORMAT, $data['ends_at'], $timezone)->startOfMinute()), 'priority' => (int) $data['priority'], ], array_values(array_unique(array_map('intval', $data['groups'] ?? []))), ]; } /** Nombre d'agents qui reçoivent réellement chaque campagne en cours (0 = masquée par une plus prioritaire). */ private function reach(Collection $running, CampaignSelector $selector): array { if ($running->isEmpty()) { return []; } $tenant = $this->tenantId(); $candidates = $running->map(fn (Campaign $c) => [ 'id' => (int) $c->id, 'priority' => (int) $c->priority, 'group_ids' => $c->groups->pluck('id')->map(fn ($id) => (int) $id)->all(), ])->values()->all(); $memberships = DB::table('group_members') ->join('directory_groups', 'directory_groups.id', '=', 'group_members.directory_group_id') ->where('directory_groups.tenant_id', $tenant) ->get(['group_members.directory_user_id as user_id', 'group_members.directory_group_id as group_id']) ->groupBy('user_id') ->map(fn (Collection $rows) => $rows->pluck('group_id')->map(fn ($id) => (int) $id)->all()); $counts = array_fill_keys(array_column($candidates, 'id'), 0); foreach (DirectoryUser::where('tenant_id', $tenant)->where('account_enabled', true)->pluck('id') as $userId) { $winner = $selector->select($candidates, $memberships->get($userId, [])); if ($winner !== null) { $counts[$winner]++; } } return $counts; } /** Largeur par défaut : taille réelle de l'image, plafonnée (une image agrandie devient floue). */ private function autoWidth(string $image): int { return min($this->library->width($image) ?? Campaign::DEFAULT_WIDTH, Campaign::DEFAULT_WIDTH); } /** Dates manipulées dans le fuseau de l'application avant stockage (Eloquent ne convertit pas). */ private function toStorage(Carbon $date): Carbon { return $date->copy()->setTimezone(config('app.timezone')); } private function isAdmin(): bool { return auth()->user()->hasRole('admin'); } /** Une campagne active ne peut être modifiée que par un administrateur. */ private function locked(Campaign $campaign): bool { return $campaign->active && ! $this->isAdmin(); } }