Integration Baniere dans le backend

This commit is contained in:
mrKamoo
2026-10-02 12:45:11 +02:00
parent 1b91590cca
commit 429dd9de5e
20 changed files with 1170 additions and 84 deletions
@@ -31,6 +31,7 @@ class AgentController extends Controller
return view('admin.agents.show', [
'agent' => $agent->load('groups:id,display_name'),
'template' => $builder->templateFor($agent),
'campaign' => $builder->campaignFor($agent),
'full' => $builder->build($agent, 'new'),
'reply' => $builder->build($agent, 'reply'),
]);
@@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Campaign;
use App\Models\DirectoryGroup;
use App\Models\DirectoryUser;
use App\Models\Template;
use App\Signature\CampaignSelector;
use App\Signature\ImageLibrary;
use App\Signature\SignatureBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Campagnes : bannière affichée sous la signature pendant une période, pour tous les agents ou des groupes AD.
* L'éditeur (service communication) prépare les campagnes ; l'activation, la modification d'une campagne
* active et la suppression sont réservées à l'administrateur.
*/
class CampaignController extends Controller
{
use ScopedToTenant;
private const DATE_FORMAT = 'Y-m-d\TH:i'; // valeur d'un <input type="datetime-local">
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<string, mixed>, 1: array<int, int>} 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();
}
}
@@ -7,42 +7,21 @@ use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Campaign;
use App\Models\Template;
use App\Signature\ImageLibrary;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* Images des signatures (logos, bannières), servies publiquement par Nginx sous /img.
* Formats acceptés : PNG, JPEG, GIF. Pas de SVG (mal supporté par Outlook et vecteur de script),
* pas de WebP (non affiché par plusieurs clients de messagerie).
*/
/** Écran « Images » : dépôt et suppression des logos et bannières (voir ImageLibrary pour les formats). */
class ImageController extends Controller
{
use ScopedToTenant;
private const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif'];
public function __construct(private readonly ImageLibrary $library) {}
public function index(): View
{
$dir = $this->dir();
$images = collect(File::files($dir))
->filter(fn ($f) => in_array(strtolower($f->getExtension()), self::EXTENSIONS, true))
->map(function ($f) {
$size = @getimagesize($f->getPathname()) ?: [null, null];
return [
'name' => $f->getFilename(),
'url' => config('signature.public_url').'/img/'.rawurlencode($f->getFilename()),
'kb' => (int) ceil($f->getSize() / 1024),
'width' => $size[0],
'height' => $size[1],
'modified' => $f->getMTime(),
'used_in' => $this->usages($f->getFilename()),
];
})
->sortByDesc('modified')->values();
$images = $this->library->all()->map(fn (array $img) => $img + ['used_in' => $this->usages($img['name'])]);
return view('admin.images.index', ['images' => $images, 'maxKb' => config('signature.image_max_kb')]);
}
@@ -50,23 +29,11 @@ class ImageController extends Controller
public function store(Request $request): RedirectResponse
{
$request->validate([
'image' => ['required', 'file', 'mimes:png,jpg,jpeg,gif', 'mimetypes:image/png,image/jpeg,image/gif', 'max:'.config('signature.image_max_kb')],
'image' => ['required', ...ImageLibrary::uploadRules()],
'name' => ['nullable', 'string', 'max:80'],
], [
'image.mimes' => 'Formats acceptés : PNG, JPEG ou GIF.',
'image.mimetypes' => 'Le fichier n\'est pas une image PNG, JPEG ou GIF valide.',
'image.max' => 'Image trop lourde (2 Mo maximum ; visez moins de 100 Ko pour une signature).',
]);
], ImageLibrary::uploadMessages('image'));
$file = $request->file('image');
abort_unless(@getimagesize($file->getRealPath()), 422, 'Image illisible.');
$ext = strtolower($file->getClientOriginalExtension()) === 'jpeg' ? 'jpg' : strtolower($file->getClientOriginalExtension());
$base = Str::slug(pathinfo($request->input('name') ?: $file->getClientOriginalName(), PATHINFO_FILENAME)) ?: 'image';
$name = $this->uniqueName($base, $ext);
$file->move($this->dir(), $name);
@chmod($this->dir().DIRECTORY_SEPARATOR.$name, 0644);
$name = $this->library->store($request->file('image'), $request->input('name'));
AuditLog::record('admin', $this->actor(), 'image.upload', null, ['file' => $name], $this->tenantId());
return back()->with('status', "Image « {$name} » en ligne. Dans un template : {{site_url}}/img/{$name}");
@@ -75,37 +42,18 @@ class ImageController extends Controller
public function destroy(string $name): RedirectResponse
{
$name = basename($name);
$path = $this->dir().DIRECTORY_SEPARATOR.$name;
abort_unless(File::isFile($path) && in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::EXTENSIONS, true), 404);
abort_unless($this->library->exists($name), 404);
if ($used = $this->usages($name)) {
return back()->withErrors(['image' => "« {$name} » est utilisée par : ".implode(', ', $used).'. Retirez-la d\'abord.']);
}
File::delete($path);
$this->library->delete($name);
AuditLog::record('admin', $this->actor(), 'image.delete', null, ['file' => $name], $this->tenantId());
return back()->with('status', "Image « {$name} » supprimée.");
}
private function dir(): string
{
$dir = config('signature.image_path');
File::ensureDirectoryExists($dir);
return $dir;
}
private function uniqueName(string $base, string $ext): string
{
$name = "{$base}.{$ext}";
for ($i = 2; File::exists($this->dir().DIRECTORY_SEPARATOR.$name); $i++) {
$name = "{$base}-{$i}.{$ext}";
}
return $name;
}
/** Templates (publiés ou brouillons) et campagnes qui référencent l'image. */
private function usages(string $name): array
{
@@ -117,6 +65,12 @@ class ImageController extends Controller
$campaigns = Campaign::where('tenant_id', $this->tenantId())->where('image_path', $name)
->pluck('name')->map(fn ($n) => "campagne {$n}");
return $templates->merge($campaigns)->all();
// La bibliothèque est commune à toutes les collectivités : une image utilisée ailleurs reste protégée.
$elsewhere = Template::where('tenant_id', '!=', $this->tenantId())
->where(fn ($q) => $q->where('html_full', 'like', $like)->orWhere('html_short', 'like', $like)
->orWhere('draft_html_full', 'like', $like)->orWhere('draft_html_short', 'like', $like))->exists()
|| Campaign::where('tenant_id', '!=', $this->tenantId())->where('image_path', $name)->exists();
return $templates->merge($campaigns)->when($elsewhere, fn ($c) => $c->push('une autre collectivité'))->all();
}
}