diff --git a/README.md b/README.md index 15440ee..5a341ba 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,21 @@ En-tête `Authorization: Bearer ` → `{ html, variant, templat - Template : groupe `signature_mail_*` de plus haute priorité, sinon template par défaut. - Réponse / transfert : variante choisie par l'agent (courte, complète, aucune), défaut `short`. - Boîte partagée déclarée : son template, avec ou sans le nom de l'agent selon le réglage de la boîte. -- Bannière : campagne active ciblant un des groupes de l'agent (ou tous), insérée par `{{{banniere}}}`. +- Bannière : une seule par signature, insérée par `{{{banniere}}}` dans la variante complète uniquement. + Parmi les campagnes actives et en cours qui ciblent l'agent (tous, ou un de ses groupes), la priorité la plus + haute l'emporte ; à égalité, la plus ancienne. + +## Campagnes (bannières) + +Écran `/admin/campagnes` : image (choisie dans la bibliothèque ou envoyée depuis le formulaire), texte alternatif +obligatoire, lien facultatif, période en heure de Paris, priorité, groupes AD ciblés (aucun = tous les agents). + +- L'éditeur prépare les campagnes : elles restent inactives. L'administrateur active, modifie une campagne + active et supprime. +- La liste indique le nombre d'agents qui reçoivent réellement chaque campagne en cours (0 = masquée par une + campagne plus prioritaire) et signale les templates publiés sans `{{{banniere}}}`. +- Aucun cache à purger : l'add-in interroge l'API à chaque nouveau message, la bannière apparaît et disparaît + donc à l'heure. Les messages déjà envoyés la conservent tant que l'image reste dans la bibliothèque. ## Syntaxe des templates diff --git a/backend/app/Http/Controllers/Admin/AgentController.php b/backend/app/Http/Controllers/Admin/AgentController.php index 06e5d21..331f624 100644 --- a/backend/app/Http/Controllers/Admin/AgentController.php +++ b/backend/app/Http/Controllers/Admin/AgentController.php @@ -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'), ]); diff --git a/backend/app/Http/Controllers/Admin/CampaignController.php b/backend/app/Http/Controllers/Admin/CampaignController.php new file mode 100644 index 0000000..922ed86 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/CampaignController.php @@ -0,0 +1,284 @@ + + + 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(); + } +} diff --git a/backend/app/Http/Controllers/Admin/ImageController.php b/backend/app/Http/Controllers/Admin/ImageController.php index fdeaa24..f86930c 100644 --- a/backend/app/Http/Controllers/Admin/ImageController.php +++ b/backend/app/Http/Controllers/Admin/ImageController.php @@ -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(); } } diff --git a/backend/app/Models/Campaign.php b/backend/app/Models/Campaign.php index 5e398bc..daab79e 100644 --- a/backend/app/Models/Campaign.php +++ b/backend/app/Models/Campaign.php @@ -4,17 +4,34 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +/** Campagne de communication : une bannière affichée sous la signature pendant une période, pour tous ou pour des groupes AD. */ class Campaign extends Model { + public const DEFAULT_WIDTH = 600; + + /** État => libellé affiché dans le back-office. */ + public const STATUSES = [ + 'running' => 'En cours', + 'scheduled' => 'Programmée', + 'inactive' => 'Inactive', + 'ended' => 'Terminée', + ]; + protected $guarded = ['id']; protected function casts(): array { - return ['starts_at' => 'datetime', 'ends_at' => 'datetime', 'active' => 'boolean']; + return [ + 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'active' => 'boolean', + 'priority' => 'integer', 'width' => 'integer', + ]; } + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } + public function groups(): BelongsToMany { return $this->belongsToMany(DirectoryGroup::class, 'campaign_group'); @@ -24,4 +41,22 @@ class Campaign extends Model { return $q->where('active', true)->where('starts_at', '<=', now())->where('ends_at', '>=', now()); } + + /** running | scheduled | inactive | ended — doit rester cohérent avec scopeRunning(). */ + public function status(): string + { + $now = now()->startOfSecond(); // même précision que la comparaison SQL de scopeRunning() + + return match (true) { + $this->ends_at->lt($now) => 'ended', + ! $this->active => 'inactive', + $this->starts_at->gt($now) => 'scheduled', + default => 'running', + }; + } + + public function displayWidth(): int + { + return $this->width ?: self::DEFAULT_WIDTH; + } } diff --git a/backend/app/Signature/CampaignSelector.php b/backend/app/Signature/CampaignSelector.php new file mode 100644 index 0000000..ae7a35e --- /dev/null +++ b/backend/app/Signature/CampaignSelector.php @@ -0,0 +1,35 @@ +}> $campaigns campagnes en cours + * @param array $userGroupIds + */ + public function select(array $campaigns, array $userGroupIds): ?int + { + $groups = array_flip($userGroupIds); + $best = null; + + foreach ($campaigns as $c) { + if ($c['group_ids'] !== [] && ! array_intersect_key($groups, array_flip($c['group_ids']))) { + continue; + } + if ($best === null + || $c['priority'] > $best['priority'] + || ($c['priority'] === $best['priority'] && $c['id'] < $best['id'])) { + $best = $c; + } + } + + return $best['id'] ?? null; + } +} diff --git a/backend/app/Signature/ImageLibrary.php b/backend/app/Signature/ImageLibrary.php new file mode 100644 index 0000000..5dbbed0 --- /dev/null +++ b/backend/app/Signature/ImageLibrary.php @@ -0,0 +1,114 @@ + 'Formats acceptés : PNG, JPEG ou GIF.', + "{$field}.mimetypes" => 'Le fichier n\'est pas une image PNG, JPEG ou GIF valide.', + "{$field}.dimensions" => 'Image illisible : le fichier est incomplet ou endommagé.', + "{$field}.uploaded" => 'L\'envoi a échoué : fichier trop lourd pour le serveur.', + "{$field}.max" => 'Image trop lourde (2 Mo maximum ; visez moins de 100 Ko pour une signature).', + ]; + } + + public function dir(): string + { + $dir = config('signature.image_path'); + File::ensureDirectoryExists($dir); + + return $dir; + } + + public function path(string $name): string + { + return $this->dir().DIRECTORY_SEPARATOR.basename($name); + } + + public function exists(string $name): bool + { + return $name !== '' && $name === basename($name) + && in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::EXTENSIONS, true) + && File::isFile($this->path($name)); + } + + public function url(string $name): string + { + return config('signature.public_url').'/img/'.rawurlencode($name); + } + + /** Largeur réelle en pixels, ou null si le fichier est illisible. */ + public function width(string $name): ?int + { + $size = $this->exists($name) ? @getimagesize($this->path($name)) : false; + + return $size ? (int) $size[0] : null; + } + + /** @return Collection */ + public function all(): Collection + { + return collect(File::files($this->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' => $this->url($f->getFilename()), + 'kb' => (int) ceil($f->getSize() / 1024), + 'width' => $size[0], + 'height' => $size[1], + 'modified' => $f->getMTime(), + ]; + }) + ->sortByDesc('modified')->values(); + } + + /** Enregistre un fichier déjà validé (uploadRules) sous un nom nettoyé, sans écraser l'existant. Retourne le nom final. */ + public function store(UploadedFile $file, ?string $name = null): string + { + // Extension déduite du contenu réel, jamais du nom envoyé par le navigateur. + $info = @getimagesize($file->getRealPath()); + $ext = [IMAGETYPE_PNG => 'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_GIF => 'gif'][$info[2] ?? 0] ?? null; + abort_unless($ext, 422, 'Image illisible.'); + $base = Str::slug(pathinfo($name ?: $file->getClientOriginalName(), PATHINFO_FILENAME)) ?: 'image'; + + $stored = "{$base}.{$ext}"; + for ($i = 2; File::exists($this->path($stored)); $i++) { + $stored = "{$base}-{$i}.{$ext}"; + } + + $file->move($this->dir(), $stored); + @chmod($this->path($stored), 0644); + + return $stored; + } + + public function delete(string $name): void + { + File::delete($this->path($name)); + } +} diff --git a/backend/app/Signature/SignatureBuilder.php b/backend/app/Signature/SignatureBuilder.php index 670d1b9..e0a0f2b 100644 --- a/backend/app/Signature/SignatureBuilder.php +++ b/backend/app/Signature/SignatureBuilder.php @@ -18,6 +18,7 @@ final class SignatureBuilder public function __construct( private readonly TemplateRenderer $renderer, private readonly TemplateSelector $selector, + private readonly CampaignSelector $campaigns, ) {} /** @@ -68,7 +69,8 @@ final class SignatureBuilder public function variables(DirectoryUser $user, string $variant = 'full'): array { $vars = VariableMap::build($user->getAttributes(), $this->allowedOverrides($user)); - $vars['banniere'] = $variant === 'full' ? $this->bannerHtml($user) : ''; + $campaign = $variant === 'full' ? $this->campaignFor($user) : null; + $vars['banniere'] = $campaign ? $this->bannerHtml($campaign) : ''; $vars['site_url'] = config('signature.public_url'); return $vars; @@ -129,27 +131,38 @@ final class SignatureBuilder return SharedMailbox::with('template')->where('tenant_id', $user->tenant_id)->where('address', $mailbox)->first(); } - private function bannerHtml(DirectoryUser $user): string + /** Campagne dont la bannière s'affiche pour cet agent en ce moment (une seule par signature). */ + public function campaignFor(DirectoryUser $user): ?Campaign { - $groupIds = $user->groups()->pluck('directory_groups.id')->all(); - - $campaign = Campaign::running() - ->where('tenant_id', $user->tenant_id) - ->where(fn ($q) => $q->whereDoesntHave('groups') - ->orWhereHas('groups', fn ($g) => $g->whereIn('directory_groups.id', $groupIds))) - ->orderByDesc('priority')->orderBy('id') - ->first(); - - if (! $campaign) { - return ''; + $running = Campaign::running()->where('tenant_id', $user->tenant_id)->with('groups:id')->get(); + if ($running->isEmpty()) { + return null; } - $src = htmlspecialchars(config('signature.public_url').'/img/'.ltrim($campaign->image_path, '/'), ENT_QUOTES); - $alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES); - $img = "\"{$alt}\""; + $id = $this->campaigns->select( + $running->map(fn (Campaign $c) => [ + 'id' => $c->id, + 'priority' => $c->priority, + 'group_ids' => $c->groups->pluck('id')->all(), + ])->all(), + $user->groups()->pluck('directory_groups.id')->all(), + ); - return $campaign->link_url - ? ''.$img.'' + return $id ? $running->firstWhere('id', $id) : null; + } + + /** HTML de la bannière d'une campagne : seul bloc injecté sans échappement, donc tout y est échappé ici. */ + public function bannerHtml(Campaign $campaign): string + { + $path = implode('/', array_map('rawurlencode', explode('/', ltrim($campaign->image_path, '/')))); + $src = htmlspecialchars(config('signature.public_url').'/img/'.$path, ENT_QUOTES); + $alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES); + $width = $campaign->displayWidth(); + $img = "\"{$alt}\""; + + // Lien : http(s) uniquement, même si la valeur a été écrite en base hors du back-office. + return preg_match('#^https?://#i', (string) $campaign->link_url) + ? ''.$img.'' : $img; } } diff --git a/backend/config/signature.php b/backend/config/signature.php index 1eb7d26..32f168f 100644 --- a/backend/config/signature.php +++ b/backend/config/signature.php @@ -52,6 +52,9 @@ return [ 'image_path' => env('SIGNATURE_IMAGE_PATH', public_path('img')), 'image_max_kb' => 2048, + // Fuseau des dates saisies et affichées dans le back-office (périodes de campagne). + 'timezone' => env('SIGNATURE_TIMEZONE', 'Europe/Paris'), + // URL publique (images, liens). 'public_url' => rtrim((string) env('APP_URL', 'https://signature.beziers-mediterranee.fr'), '/'), diff --git a/backend/database/migrations/2026_10_02_000001_add_width_to_campaigns.php b/backend/database/migrations/2026_10_02_000001_add_width_to_campaigns.php new file mode 100644 index 0000000..4847cbf --- /dev/null +++ b/backend/database/migrations/2026_10_02_000001_add_width_to_campaigns.php @@ -0,0 +1,23 @@ +unsignedSmallInteger('width')->nullable()->after('link_url'); + }); + } + + public function down(): void + { + Schema::table('campaigns', function (Blueprint $t) { + $t->dropColumn('width'); + }); + } +}; diff --git a/backend/resources/views/admin/agents/show.blade.php b/backend/resources/views/admin/agents/show.blade.php index 51b5fe5..8ac5904 100644 --- a/backend/resources/views/admin/agents/show.blade.php +++ b/backend/resources/views/admin/agents/show.blade.php @@ -20,6 +20,14 @@ {{ $template->name }} @else — aucun (pas de template par défaut publié) — @endif + Bannière + @if ($campaign) + {{ $campaign->name }} + @if ($template && ! str_contains((string) $template->html_full, 'banniere')) + non affichée : le template n'a pas de bannière + @endif + @else — aucune campagne en cours pour cet agent — @endif + Synchronisé le{{ $agent->synced_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }} diff --git a/backend/resources/views/admin/campaigns/form.blade.php b/backend/resources/views/admin/campaigns/form.blade.php new file mode 100644 index 0000000..30c57db --- /dev/null +++ b/backend/resources/views/admin/campaigns/form.blade.php @@ -0,0 +1,153 @@ +@extends('layouts.app') +@section('title', ($campaign->exists ? $campaign->name : 'Nouvelle campagne').' — Signatures CABM') +@section('content') + @php + $isAdmin = auth()->user()->hasRole('admin'); + $tz = config('signature.timezone'); + $local = fn ($d) => $d?->timezone($tz)->format('Y-m-d\TH:i'); + $currentImage = old('image_path', $campaign->image_path); + $currentImage = is_string($currentImage) ? $currentImage : null; + $inLibrary = $images->contains('name', $currentImage); + $checked = collect(old('groups', $selected))->map(fn ($id) => (int) $id)->all(); + $status = $campaign->exists ? $campaign->status() : null; + @endphp +
+ ← Campagnes +

{{ $campaign->exists ? $campaign->name : 'Nouvelle campagne' }}

+ @if ($status) + {{ \App\Models\Campaign::STATUSES[$status] }} + @endif +
+ + @if ($locked) +
Cette campagne est active : seul un administrateur peut la modifier.
+ @endif + +
+ @csrf + @if ($campaign->exists) @method('PUT') @endif +
+
+
+

Bannière

+ + + + + + + +

Visez 600 px de large et moins de 100 Ko : les destinataires la téléchargent à chaque ouverture du message.

+ + + + +

Affiché quand le destinataire bloque les images, et lu par les lecteurs d'écran. Résumez le message de la bannière.

+ + + + + + +

Vide : largeur réelle de l'image, plafonnée à 600 px.

+
+ +
+

Diffusion

+
+
+ + +
+
+ + +
+
+

Heure de Paris. La bannière cesse d'être insérée à la fin ; les messages déjà envoyés la conservent.

+ + + +

Si plusieurs campagnes en cours concernent le même agent, la priorité la plus haute s'affiche.

+ +
+ Destinataires +

Aucune case cochée : tous les agents. Sinon, seuls les membres des groupes cochés.

+
+ @forelse ($groups as $g) + + @empty + Aucun groupe synchronisé. + @endforelse +
+
+ + @if ($isAdmin) + + +

Décochée, la bannière n'est jamais insérée, même pendant la période.

+ @else +

La campagne reste inactive tant qu'un administrateur ne l'a pas activée.

+ @endif +
+
+ +
+ + Annuler +
+
+
+ + @if ($campaign->exists) +
+

Rendu enregistré

+

Tel qu'inséré à l'emplacement {{ '{'.'{'.'{banniere}'.'}'.'}' }} du template. Pour le voir dans une signature complète, ouvrez un agent dans l'onglet Agents.

+ @include('partials.signature-frame', ['html' => $bannerHtml, 'title' => 'Aperçu de la bannière']) +
+ @endif +@endsection + +@push('scripts') + +@endpush diff --git a/backend/resources/views/admin/campaigns/index.blade.php b/backend/resources/views/admin/campaigns/index.blade.php new file mode 100644 index 0000000..e15aefb --- /dev/null +++ b/backend/resources/views/admin/campaigns/index.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app') +@section('title', 'Campagnes — Signatures CABM') +@section('content') + @php + $isAdmin = auth()->user()->hasRole('admin'); + $tz = config('signature.timezone'); + $badge = ['running' => 'ok', 'scheduled' => '', 'inactive' => 'warn', 'ended' => '']; + $variable = '{'.'{'.'{banniere}'.'}'.'}'; + $period = fn ($d) => $d->timezone($tz)->format('d/m/Y H:i'); + @endphp +
+

Campagnes

+ + Nouvelle campagne +
+

+ Une campagne ajoute une bannière sous la signature pendant une période, pour tous les agents ou pour certains groupes AD. + Une seule bannière par signature : si plusieurs campagnes en cours concernent un agent, celle dont la priorité + est la plus haute l'emporte (à égalité : la plus ancienne). La bannière n'apparaît que dans la signature complète, + pas dans la signature courte des réponses, et seulement si le template contient {{ $variable }}. + @unless ($isAdmin) Vous préparez les campagnes ; un administrateur les active. @endunless +

+ + @if ($withoutBanner->isNotEmpty()) +
+ Ces templates publiés ne contiennent pas {{ $variable }} : leurs agents ne verront aucune bannière — + {{ $withoutBanner->join(', ') }}. +
+ @endif + +
+ + + + @forelse ($campaigns as $c) + @php + $status = $c->status(); + $locked = $c->active && ! $isAdmin; + @endphp + + + + + + + + + + @empty + + @endforelse + +
BannièreCampagnePériodeDestinatairesPrioritéÉtat
+ @if (in_array($c->id, $missing, true)) + image manquante +
{{ $c->image_path }}
+ @else + {{ $c->alt_text }} + @endif +
+ {{ $c->name }} +
+ @if ($c->link_url) + Lien : {{ \Illuminate\Support\Str::limit($c->link_url, 50) }} + @else + Sans lien + @endif +
+
{{ $period($c->starts_at) }}
→ {{ $period($c->ends_at) }}
{{ $c->groups->isEmpty() ? 'Tous les agents' : $c->groups->pluck('display_name')->join(', ') }}{{ $c->priority }} + {{ \App\Models\Campaign::STATUSES[$status] }} + @if ($status === 'running') +
+ @if (($reach[$c->id] ?? 0) > 0) + vue par {{ $reach[$c->id] }} agent{{ $reach[$c->id] > 1 ? 's' : '' }} + @else + aucun agent : masquée par une campagne plus prioritaire, ou groupes vides + @endif +
+ @elseif ($status === 'inactive') +
{{ $isAdmin ? 'à activer' : 'en attente d\'un administrateur' }}
+ @endif +
+ {{ $locked ? 'Voir' : 'Modifier' }} + @if ($isAdmin) + @if ($status !== 'ended') +
+ @csrf + +
+ @endif +
+ @csrf @method('DELETE') + +
+ @endif +
Aucune campagne. Les signatures sont envoyées sans bannière.
+

Vérifiez la bannière reçue par un agent précis dans l'onglet Agents.

+
+@endsection diff --git a/backend/resources/views/admin/dashboard.blade.php b/backend/resources/views/admin/dashboard.blade.php index f561d0a..681541b 100644 --- a/backend/resources/views/admin/dashboard.blade.php +++ b/backend/resources/views/admin/dashboard.blade.php @@ -26,7 +26,7 @@
{{ $groups->count() }}
groupes signature_*
{{ $assignments }}
affectations
-
{{ $campaigns }}
campagnes en cours
+
campagnes en cours
diff --git a/backend/resources/views/layouts/app.blade.php b/backend/resources/views/layouts/app.blade.php index 06951a6..568dc6c 100644 --- a/backend/resources/views/layouts/app.blade.php +++ b/backend/resources/views/layouts/app.blade.php @@ -28,7 +28,7 @@ table.list th, table.list td { text-align:left; padding:8px; border-bottom:1px solid var(--line); vertical-align:middle; } table.list th { font-size:13px; color:var(--muted); font-weight:600; } label { display:block; font-weight:600; font-size:13px; margin:12px 0 4px; } - input[type=text], input[type=number], input[type=search], select, textarea { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:6px; font:inherit; background:#fff; } + input[type=text], input[type=number], input[type=search], input[type=url], input[type=datetime-local], select, textarea { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:6px; font:inherit; background:#fff; } textarea.code { font-family:Consolas, "Cascadia Mono", monospace; font-size:13px; line-height:1.45; min-height:260px; resize:vertical; tab-size:2; } input.small { width:90px; } .btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:7px 14px; font:inherit; font-size:14px; cursor:pointer; text-decoration:none; } @@ -42,6 +42,12 @@ .badge.warn { background:#fdf1dc; color:var(--warn); } .flash { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#e5f4ea; color:var(--ok); border:1px solid #bfe3cb; } .errors { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#fde8e6; color:var(--danger); border:1px solid #f5c2bd; } + .notice { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#fdf1dc; color:var(--warn); border:1px solid #f3d9a4; } + label.check { font-weight:400; font-size:14px; margin:4px 0; } + label.check input { margin-right:6px; } + fieldset { border:1px solid var(--line); border-radius:6px; margin:12px 0 0; padding:8px 12px 12px; } + legend { font-weight:600; font-size:13px; padding:0 4px; } + .scroll { max-height:220px; overflow:auto; } .chips button { background:var(--accent-soft); color:var(--accent); border:0; border-radius:12px; padding:3px 9px; margin:2px; font:12px Consolas, monospace; cursor:pointer; } iframe.sig { width:100%; min-height:220px; border:1px solid var(--line); border-radius:6px; background:#fff; resize:vertical; } form.inline { display:inline; } @@ -56,6 +62,7 @@ request()->routeIs('admin.dashboard')])>Tableau de bord request()->routeIs('admin.templates.*')])>Templates request()->routeIs('admin.images.*')])>Images + request()->routeIs('admin.campaigns.*')])>Campagnes @if (auth()->user()->hasRole('admin')) request()->routeIs('admin.assignments.*')])>Affectations request()->routeIs('admin.fields.*')])>Champs agents diff --git a/backend/routes/web.php b/backend/routes/web.php index 7123ca0..e6c63b3 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Admin\AgentController; use App\Http\Controllers\Admin\AssignmentController; +use App\Http\Controllers\Admin\CampaignController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\EditableFieldController; use App\Http\Controllers\Admin\ImageController; @@ -41,6 +42,13 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na Route::get('/images', [ImageController::class, 'index'])->name('images.index'); Route::post('/images', [ImageController::class, 'store'])->name('images.store'); + // Campagnes (bannières) — éditeur : préparation ; administrateur : activation, suppression + Route::get('/campagnes', [CampaignController::class, 'index'])->name('campaigns.index'); + Route::get('/campagnes/create', [CampaignController::class, 'create'])->name('campaigns.create'); + Route::post('/campagnes', [CampaignController::class, 'store'])->name('campaigns.store'); + Route::get('/campagnes/{campaign}', [CampaignController::class, 'edit'])->name('campaigns.edit'); + Route::put('/campagnes/{campaign}', [CampaignController::class, 'update'])->name('campaigns.update'); + Route::middleware('role:admin')->group(function () { Route::post('/templates/{template}/publish', [TemplateController::class, 'publish'])->name('templates.publish'); Route::post('/templates/{template}/default', [TemplateController::class, 'makeDefault'])->name('templates.default'); @@ -51,6 +59,9 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na Route::put('/affectations/{assignment}', [AssignmentController::class, 'update'])->name('assignments.update'); Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy'); + Route::post('/campagnes/{campaign}/activation', [CampaignController::class, 'toggle'])->name('campaigns.toggle'); + Route::delete('/campagnes/{campaign}', [CampaignController::class, 'destroy'])->name('campaigns.destroy'); + Route::post('/synchro', SyncController::class)->name('sync'); Route::get('/champs', [EditableFieldController::class, 'index'])->name('fields.index'); Route::put('/champs', [EditableFieldController::class, 'update'])->name('fields.update'); diff --git a/backend/tests/Feature/AdminCampaignsTest.php b/backend/tests/Feature/AdminCampaignsTest.php new file mode 100644 index 0000000..e41e5d5 --- /dev/null +++ b/backend/tests/Feature/AdminCampaignsTest.php @@ -0,0 +1,235 @@ + '22222222-2222-2222-2222-222222222222', 'admin' => '33333333-3333-3333-3333-333333333333']; + + protected function setUp(): void + { + parent::setUp(); + $this->dir = storage_path('framework/testing/img-'.uniqid()); + config(['signature.admin_allowed_cidrs' => [], 'signature.image_path' => $this->dir, 'signature.timezone' => 'Europe/Paris']); + File::ensureDirectoryExists($this->dir); + file_put_contents($this->dir.'/banniere.png', base64_decode(self::PNG)); + + $this->tenant = Tenant::create(['name' => 'CABM', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111']); + $this->group = DirectoryGroup::create(['tenant_id' => $this->tenant->id, 'entra_oid' => '44444444-4444-4444-4444-444444444444', 'display_name' => 'signature_mail_directeurs']); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->dir); + parent::tearDown(); + } + + private function user(string $role): User + { + return User::firstOrCreate(['entra_oid' => self::OIDS[$role]], ['name' => $role, 'email' => "{$role}@x.fr", 'tenant_id' => $this->tenant->id, 'role' => $role]); + } + + private function payload(array $over = []): array + { + return array_merge([ + 'name' => 'Encombrants', 'image_path' => 'banniere.png', 'alt_text' => 'Collecte des encombrants', + 'link_url' => 'https://www.beziers-mediterranee.fr/encombrants', + 'starts_at' => '2026-11-01T08:00', 'ends_at' => '2026-11-15T18:00', 'priority' => 20, + ], $over); + } + + private function campaign(array $over = []): Campaign + { + return Campaign::create($over + [ + 'tenant_id' => $this->tenant->id, 'name' => 'Encombrants', 'image_path' => 'banniere.png', 'alt_text' => 'Collecte', + 'starts_at' => now()->subDay(), 'ends_at' => now()->addDay(), 'priority' => 10, 'active' => true, + ]); + } + + private function agent(int $n, bool $inGroup = false): DirectoryUser + { + $agent = DirectoryUser::create([ + 'tenant_id' => $this->tenant->id, 'entra_oid' => sprintf('55555555-5555-5555-5555-%012d', $n), + 'display_name' => "Agent {$n}", 'mail' => "agent{$n}@beziers-mediterranee.fr", 'account_enabled' => true, + ]); + if ($inGroup) { + $this->group->members()->attach($agent->id); + } + + return $agent; + } + + public function test_editeur_cree_une_campagne_ciblee_qui_reste_inactive(): void + { + $this->actingAs($this->user('editor')) + ->post('/admin/campagnes', $this->payload(['groups' => [$this->group->id], 'active' => 1])) + ->assertRedirect(route('admin.campaigns.index'))->assertSessionHasNoErrors(); + + $c = Campaign::firstWhere('name', 'Encombrants'); + $this->assertFalse($c->active, 'seul un administrateur active une campagne'); + $this->assertTrue($c->starts_at->equalTo(Carbon::parse('2026-11-01 08:00', 'Europe/Paris')), 'dates saisies en heure de Paris'); + $this->assertTrue($c->ends_at->equalTo(Carbon::parse('2026-11-15 18:00', 'Europe/Paris'))); + $this->assertSame(1, $c->width, 'largeur automatique = largeur réelle de l\'image'); + $this->assertSame([$this->group->id], $c->groups->pluck('id')->all()); + $this->assertTrue(AuditLog::where('action', 'campaign.create')->exists()); + + $this->actingAs($this->user('editor'))->get('/admin/campagnes')->assertOk()->assertSee('Encombrants')->assertSee('signature_mail_directeurs'); + } + + public function test_image_envoyee_depuis_le_formulaire(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'png'); + file_put_contents($tmp, base64_decode(self::PNG)); + + $this->actingAs($this->user('admin'))->post('/admin/campagnes', $this->payload([ + 'image_path' => null, 'image_file' => new UploadedFile($tmp, 'Banniere Noel 2026.png', 'image/png', null, true), + 'width' => 450, 'active' => 1, + ]))->assertSessionHasNoErrors(); + + $c = Campaign::firstWhere('name', 'Encombrants'); + $this->assertSame('banniere-noel-2026.png', $c->image_path); + $this->assertFileExists($this->dir.'/banniere-noel-2026.png'); + $this->assertSame(450, $c->width); + $this->assertTrue($c->active); + } + + public function test_refuse_les_saisies_invalides(): void + { + $admin = $this->user('admin'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['ends_at' => '2026-10-01T08:00']))->assertSessionHasErrors('ends_at'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['link_url' => 'javascript:alert(1)']))->assertSessionHasErrors('link_url'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['image_path' => 'absente.png']))->assertSessionHasErrors('image_path'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['image_path' => '../banniere.png']))->assertSessionHasErrors('image_path'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['image_path' => null]))->assertSessionHasErrors('image_path'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['alt_text' => '']))->assertSessionHasErrors('alt_text'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload(['groups' => [999]]))->assertSessionHasErrors('groups.0'); + + $this->assertSame(0, Campaign::count()); + } + + public function test_activation_modification_et_suppression_reservees_a_l_admin(): void + { + $c = $this->campaign(['active' => false]); + $editor = $this->user('editor'); + $admin = $this->user('admin'); + + $this->actingAs($editor)->post("/admin/campagnes/{$c->id}/activation")->assertForbidden(); + $this->actingAs($editor)->delete("/admin/campagnes/{$c->id}")->assertForbidden(); + + // Inactive : l'éditeur la modifie, sans pouvoir l'activer. + $this->actingAs($editor)->put("/admin/campagnes/{$c->id}", $this->payload(['name' => 'Préparée', 'active' => 1]))->assertSessionHasNoErrors(); + $this->assertSame('Préparée', $c->refresh()->name); + $this->assertFalse($c->active); + + $this->actingAs($admin)->post("/admin/campagnes/{$c->id}/activation")->assertSessionHasNoErrors(); + $this->assertTrue($c->refresh()->active); + + // Active : verrouillée pour l'éditeur. + $this->actingAs($editor)->put("/admin/campagnes/{$c->id}", $this->payload(['name' => 'Piratée']))->assertForbidden(); + $this->actingAs($editor)->get("/admin/campagnes/{$c->id}")->assertOk()->assertSee('seul un administrateur peut la modifier'); + $this->assertSame('Préparée', $c->refresh()->name); + + $this->actingAs($admin)->put("/admin/campagnes/{$c->id}", $this->payload(['name' => 'Validée', 'active' => 0]))->assertSessionHasNoErrors(); + $this->assertSame('Validée', $c->refresh()->name); + $this->assertFalse($c->active); + + $this->actingAs($admin)->delete("/admin/campagnes/{$c->id}")->assertSessionHasNoErrors(); + $this->assertSame(0, Campaign::count()); + $this->assertFileExists($this->dir.'/banniere.png'); + } + + public function test_liste_indique_la_portee_reelle_et_les_templates_sans_banniere(): void + { + $this->agent(1); + $this->agent(2); + $this->agent(3, inGroup: true); + + $this->campaign(['name' => 'Générale', 'priority' => 10]); + $this->campaign(['name' => 'Directeurs', 'priority' => 50])->groups()->attach($this->group->id); + $this->campaign(['name' => 'Masquée', 'priority' => 5]); + $this->campaign(['name' => 'Future', 'priority' => 100, 'starts_at' => now()->addWeek(), 'ends_at' => now()->addWeeks(2)]); + $this->campaign(['name' => 'Passée', 'priority' => 100, 'starts_at' => now()->subWeeks(2), 'ends_at' => now()->subWeek()]); + + Template::create(['tenant_id' => $this->tenant->id, 'name' => 'Sobre', 'draft_html_full' => '{{prenom}}'])->publish(); + Template::create(['tenant_id' => $this->tenant->id, 'name' => 'Complet', 'draft_html_full' => '{{prenom}}{{{banniere}}}'])->publish(); + + $this->actingAs($this->user('admin'))->get('/admin/campagnes')->assertOk() + ->assertSeeInOrder(['Directeurs', 'vue par 1 agent', 'Générale', 'vue par 2 agents', 'Masquée', 'aucun agent', 'Future', 'Programmée', 'Passée', 'Terminée']) + ->assertSee('Sobre') + ->assertDontSee('Complet'); + } + + public function test_largeur_automatique_suit_l_image_et_formulaire_reenregistrable(): void + { + $admin = $this->user('admin'); + $this->actingAs($admin)->post('/admin/campagnes', $this->payload())->assertSessionHasNoErrors(); + $c = Campaign::firstWhere('name', 'Encombrants'); + $this->assertSame(1, $c->width); + + // Le formulaire d'édition laisse la largeur vide (automatique) : il se réenregistre tel quel. + $this->actingAs($admin)->get("/admin/campagnes/{$c->id}")->assertOk()->assertSee('min="100" max="800" value=""', false); + $this->actingAs($admin)->put("/admin/campagnes/{$c->id}", $this->payload(['width' => null]))->assertSessionHasNoErrors(); + + // Une largeur saisie est conservée et réaffichée. + $this->actingAs($admin)->put("/admin/campagnes/{$c->id}", $this->payload(['width' => 450]))->assertSessionHasNoErrors(); + $this->assertSame(450, $c->refresh()->width); + $this->actingAs($admin)->get("/admin/campagnes/{$c->id}")->assertSee('value="450"', false); + } + + public function test_messages_d_activation(): void + { + $admin = $this->user('admin'); + $running = $this->campaign(['active' => false]); + $ended = $this->campaign(['name' => 'Passée', 'starts_at' => now()->subWeeks(2), 'ends_at' => now()->subWeek()]); + + $this->actingAs($admin)->post("/admin/campagnes/{$running->id}/activation")->assertSessionHas('status', fn ($m) => str_contains($m, 'dès le prochain message')); + $this->actingAs($admin)->post("/admin/campagnes/{$running->id}/activation")->assertSessionHas('status', fn ($m) => str_contains($m, 'désactivée')); + $this->actingAs($admin)->post("/admin/campagnes/{$ended->id}/activation")->assertSessionHas('status', fn ($m) => str_contains($m, 'désactivée')); + $this->assertFalse($ended->refresh()->active); + } + + public function test_image_endommagee_refusee_avec_un_message(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'png'); + file_put_contents($tmp, substr(base64_decode(self::PNG), 0, 20)); // en-tête PNG tronqué + + $this->actingAs($this->user('admin'))->post('/admin/campagnes', $this->payload([ + 'image_path' => null, 'image_file' => new UploadedFile($tmp, 'cassee.png', 'image/png', null, true), + ]))->assertSessionHasErrors('image_file'); + $this->assertSame(0, Campaign::count()); + } + + public function test_cloisonnement_par_tenant(): void + { + $other = Tenant::create(['name' => 'Commune', 'entra_tenant_id' => '99999999-9999-9999-9999-999999999999']); + $foreign = $this->campaign(['tenant_id' => $other->id, 'name' => 'Ailleurs']); + $admin = $this->user('admin'); + + $this->actingAs($admin)->get("/admin/campagnes/{$foreign->id}")->assertNotFound(); + $this->actingAs($admin)->delete("/admin/campagnes/{$foreign->id}")->assertNotFound(); + $this->actingAs($admin)->get('/admin/campagnes')->assertOk()->assertDontSee('Ailleurs'); + $this->assertSame(1, Campaign::count()); + } +} diff --git a/backend/tests/Feature/AdminImagesTest.php b/backend/tests/Feature/AdminImagesTest.php index c162c19..67a469d 100644 --- a/backend/tests/Feature/AdminImagesTest.php +++ b/backend/tests/Feature/AdminImagesTest.php @@ -59,6 +59,14 @@ class AdminImagesTest extends TestCase $this->actingAs($editor)->get('/admin/images')->assertOk()->assertSee('logo-cabm-2.png'); } + public function test_extension_deduite_du_contenu_et_non_du_nom_envoye(): void + { + $this->actingAs($this->user('editor'))->post('/admin/images', ['image' => $this->png('photo.gif')])->assertSessionHasNoErrors(); + + $this->assertFileExists($this->dir.'/photo.png'); + $this->assertFileDoesNotExist($this->dir.'/photo.gif'); + } + public function test_refuse_un_faux_png_et_le_svg(): void { $tmp = tempnam(sys_get_temp_dir(), 'x'); @@ -85,4 +93,23 @@ class AdminImagesTest extends TestCase $this->actingAs($admin)->delete('/admin/images/logo.png')->assertSessionHasNoErrors(); $this->assertFileDoesNotExist($this->dir.'/logo.png'); } + + public function test_image_utilisee_par_une_campagne_ou_une_autre_collectivite_non_supprimable(): void + { + file_put_contents($this->dir.'/banniere.png', base64_decode(self::PNG)); + $admin = $this->user('admin'); + $other = Tenant::create(['name' => 'Commune', 'entra_tenant_id' => '99999999-9999-9999-9999-999999999999']); + $campaign = \App\Models\Campaign::create(['tenant_id' => $other->id, 'name' => 'Ailleurs', 'image_path' => 'banniere.png', + 'alt_text' => 'x', 'starts_at' => now(), 'ends_at' => now()->addDay()]); + + $this->actingAs($admin)->delete('/admin/images/banniere.png')->assertSessionHasErrors('image'); + $this->assertFileExists($this->dir.'/banniere.png'); + + $campaign->update(['tenant_id' => $this->tenant->id]); + $this->actingAs($admin)->delete('/admin/images/banniere.png')->assertSessionHasErrors('image'); + + $campaign->delete(); + $this->actingAs($admin)->delete('/admin/images/banniere.png')->assertSessionHasNoErrors(); + $this->assertFileDoesNotExist($this->dir.'/banniere.png'); + } } diff --git a/backend/tests/Feature/SignatureApiTest.php b/backend/tests/Feature/SignatureApiTest.php index d94993c..d37f9f0 100644 --- a/backend/tests/Feature/SignatureApiTest.php +++ b/backend/tests/Feature/SignatureApiTest.php @@ -115,4 +115,34 @@ class SignatureApiTest extends TestCase $this->assertStringContainsString('/img/campagnes/enc.png', $html); $this->assertStringContainsString('alt="Collecte"', $html); } + + public function test_une_seule_banniere_selon_priorite_ciblage_et_periode(): void + { + $base = ['tenant_id' => $this->tenant->id, 'alt_text' => 'x', 'starts_at' => now()->subDay(), 'ends_at' => now()->addDay()]; + $group = DirectoryGroup::create(['tenant_id' => $this->tenant->id, 'entra_oid' => '33333333-3333-3333-3333-333333333333', 'display_name' => 'signature_mail_directeurs']); + + Campaign::create($base + ['name' => 'Générale', 'image_path' => 'generale.png', 'priority' => 10]); + Campaign::create($base + ['name' => 'Directeurs', 'image_path' => 'directeurs.png', 'priority' => 50, 'width' => 450, + 'link_url' => 'https://www.beziers-mediterranee.fr/voeux?a=1&b=2'])->groups()->attach($group->id); + // Jamais servies, malgré une priorité supérieure : inactive, pas commencée, terminée. + Campaign::create($base + ['name' => 'Inactive', 'image_path' => 'inactive.png', 'priority' => 100, 'active' => false]); + Campaign::create(['name' => 'Future', 'image_path' => 'future.png', 'priority' => 100, 'starts_at' => now()->addHour()] + $base); + Campaign::create(['name' => 'Passée', 'image_path' => 'passee.png', 'priority' => 100, 'ends_at' => now()->subHour()] + $base); + + // Hors du groupe ciblé : campagne générale. + $html = $this->signature()->assertOk()->json('html'); + $this->assertStringContainsString('/img/generale.png', $html); + $this->assertStringNotContainsString('directeurs.png', $html); + + // Dans le groupe : la campagne ciblée, plus prioritaire, remplace la générale. + $group->members()->attach($this->agent->id); + $html = $this->signature()->json('html'); + $this->assertStringContainsString('/img/directeurs.png', $html); + $this->assertStringContainsString('width="450"', $html); + $this->assertStringContainsString('assertSame(1, substr_count($html, 'signature(['context' => 'reply'])->assertJson(['html' => 'd Jean']); + } } diff --git a/backend/tests/Unit/CampaignSelectorTest.php b/backend/tests/Unit/CampaignSelectorTest.php new file mode 100644 index 0000000..790d5cb --- /dev/null +++ b/backend/tests/Unit/CampaignSelectorTest.php @@ -0,0 +1,39 @@ + 1, 'priority' => 10, 'group_ids' => []], // générale + ['id' => 2, 'priority' => 50, 'group_ids' => [200]], // directeurs + ['id' => 3, 'priority' => 50, 'group_ids' => [300, 400]], // même priorité, plus récente + ['id' => 4, 'priority' => 10, 'group_ids' => []], // générale plus récente + ]; + + public function test_campagne_generale_pour_un_agent_hors_groupe(): void + { + $this->assertSame(1, (new CampaignSelector)->select($this->campaigns, [])); + $this->assertSame(1, (new CampaignSelector)->select($this->campaigns, [999])); + } + + public function test_campagne_ciblee_prioritaire_remplace_la_generale(): void + { + $this->assertSame(2, (new CampaignSelector)->select($this->campaigns, [200])); + $this->assertSame(3, (new CampaignSelector)->select($this->campaigns, [400])); + } + + public function test_egalite_departagee_par_anciennete(): void + { + $this->assertSame(2, (new CampaignSelector)->select($this->campaigns, [400, 200])); + } + + public function test_aucune_banniere_sans_campagne_applicable(): void + { + $this->assertNull((new CampaignSelector)->select([], [200])); + $this->assertNull((new CampaignSelector)->select([['id' => 9, 'priority' => 0, 'group_ids' => [5]]], [200])); + } +}