Integration Baniere dans le backend
This commit is contained in:
@@ -67,7 +67,21 @@ En-tête `Authorization: Bearer <jeton Entra NAA>` → `{ 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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Choix de la bannière d'un agent parmi les campagnes en cours (sans dépendance au framework).
|
||||
* Règle : une campagne sans groupe cible tous les agents ; sinon l'agent doit appartenir à l'un de ses groupes.
|
||||
* Une seule bannière par signature : la priorité la plus haute l'emporte ;
|
||||
* à priorité égale, la campagne la plus ancienne (id le plus petit) gagne, pour un résultat stable.
|
||||
*/
|
||||
final class CampaignSelector
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id:int, priority:int, group_ids:array<int, int>}> $campaigns campagnes en cours
|
||||
* @param array<int, int> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Bibliothèque des images de signature (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).
|
||||
*/
|
||||
final class ImageLibrary
|
||||
{
|
||||
public const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif'];
|
||||
|
||||
/** Règles de validation d'un fichier déposé. */
|
||||
public static function uploadRules(): array
|
||||
{
|
||||
return ['file', 'mimes:png,jpg,jpeg,gif', 'mimetypes:image/png,image/jpeg,image/gif', 'dimensions:min_width=1', 'max:'.config('signature.image_max_kb')];
|
||||
}
|
||||
|
||||
/** Messages associés, à préfixer par le nom du champ. */
|
||||
public static function uploadMessages(string $field): array
|
||||
{
|
||||
return [
|
||||
"{$field}.mimes" => '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<int, array{name:string, url:string, kb:int, width:?int, height:?int, modified:int}> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"600\" style=\"display:block;max-width:100%;height:auto;border:0\">";
|
||||
$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
|
||||
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'">'.$img.'</a>'
|
||||
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 = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"{$width}\" style=\"display:block;width:{$width}px;max-width:100%;height:auto;border:0\">";
|
||||
|
||||
// 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)
|
||||
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'" target="_blank" rel="noopener">'.$img.'</a>'
|
||||
: $img;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'), '/'),
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Largeur d'affichage de la bannière (px). Null = 600, valeur historique. */
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('campaigns', function (Blueprint $t) {
|
||||
$t->unsignedSmallInteger('width')->nullable()->after('link_url');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('campaigns', function (Blueprint $t) {
|
||||
$t->dropColumn('width');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,14 @@
|
||||
<a href="{{ route('admin.templates.edit', $template) }}">{{ $template->name }}</a>
|
||||
@else — aucun (pas de template par défaut publié) — @endif
|
||||
</td></tr>
|
||||
<tr><th>Bannière</th><td>
|
||||
@if ($campaign)
|
||||
<a href="{{ route('admin.campaigns.edit', $campaign) }}">{{ $campaign->name }}</a>
|
||||
@if ($template && ! str_contains((string) $template->html_full, 'banniere'))
|
||||
<span class="badge warn">non affichée : le template n'a pas de bannière</span>
|
||||
@endif
|
||||
@else — aucune campagne en cours pour cet agent — @endif
|
||||
</td></tr>
|
||||
<tr><th>Synchronisé le</th><td class="muted">{{ $agent->synced_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<a href="{{ route('admin.campaigns.index') }}">← Campagnes</a>
|
||||
<h1 style="margin:0 0 0 12px">{{ $campaign->exists ? $campaign->name : 'Nouvelle campagne' }}</h1>
|
||||
@if ($status)
|
||||
<span class="badge {{ ['running' => 'ok', 'inactive' => 'warn'][$status] ?? '' }}">{{ \App\Models\Campaign::STATUSES[$status] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($locked)
|
||||
<div class="notice" role="note">Cette campagne est active : seul un administrateur peut la modifier.</div>
|
||||
@endif
|
||||
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="{{ $campaign->exists ? route('admin.campaigns.update', $campaign) : route('admin.campaigns.store') }}">
|
||||
@csrf
|
||||
@if ($campaign->exists) @method('PUT') @endif
|
||||
<fieldset @disabled($locked) style="border:0;margin:0;padding:0">
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Bannière</h2>
|
||||
<label for="name">Nom de la campagne</label>
|
||||
<input type="text" id="name" name="name" maxlength="120" required value="{{ old('name', $campaign->name) }}" placeholder="ex. Collecte des encombrants — octobre">
|
||||
|
||||
<label for="image_path">Image</label>
|
||||
<select id="image_path" name="image_path">
|
||||
<option value="">— choisir dans la bibliothèque —</option>
|
||||
@if ($currentImage && ! $inLibrary)
|
||||
<option value="{{ $currentImage }}" selected>{{ $currentImage }} (introuvable)</option>
|
||||
@endif
|
||||
@foreach ($images as $img)
|
||||
<option value="{{ $img['name'] }}" data-src="{{ asset('img/'.$img['name']) }}" data-width="{{ $img['width'] }}" @selected($currentImage === $img['name'])>
|
||||
{{ $img['name'] }} — {{ $img['width'] }}×{{ $img['height'] }} px, {{ $img['kb'] }} Ko
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label for="image_file">… ou envoyer une nouvelle image (PNG, JPEG, GIF)</label>
|
||||
<input type="file" id="image_file" name="image_file" accept="image/png,image/jpeg,image/gif">
|
||||
<p class="muted">Visez 600 px de large et moins de 100 Ko : les destinataires la téléchargent à chaque ouverture du message.</p>
|
||||
<img id="thumb" alt="" hidden style="max-width:100%;max-height:160px;border:1px solid var(--line);background:#fff">
|
||||
|
||||
<label for="alt_text">Texte alternatif</label>
|
||||
<input type="text" id="alt_text" name="alt_text" maxlength="150" required value="{{ old('alt_text', $campaign->alt_text) }}" placeholder="ex. Collecte des encombrants : inscrivez-vous avant le 15 octobre">
|
||||
<p class="muted">Affiché quand le destinataire bloque les images, et lu par les lecteurs d'écran. Résumez le message de la bannière.</p>
|
||||
|
||||
<label for="link_url">Lien au clic (facultatif)</label>
|
||||
<input type="url" id="link_url" name="link_url" maxlength="255" value="{{ old('link_url', $campaign->link_url) }}" placeholder="https://www.beziers-mediterranee.fr/…">
|
||||
|
||||
<label for="width">Largeur d'affichage (px, facultatif)</label>
|
||||
<input type="number" id="width" name="width" style="width:140px" min="100" max="800" value="{{ old('width', $width) }}" placeholder="auto">
|
||||
<p class="muted">Vide : largeur réelle de l'image, plafonnée à 600 px.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Diffusion</h2>
|
||||
<div class="row">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<label for="starts_at">Début</label>
|
||||
<input type="datetime-local" id="starts_at" name="starts_at" required value="{{ old('starts_at', $local($campaign->starts_at)) }}">
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px">
|
||||
<label for="ends_at">Fin</label>
|
||||
<input type="datetime-local" id="ends_at" name="ends_at" required value="{{ old('ends_at', $local($campaign->ends_at)) }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Heure de Paris. La bannière cesse d'être insérée à la fin ; les messages déjà envoyés la conservent.</p>
|
||||
|
||||
<label for="priority">Priorité</label>
|
||||
<input class="small" type="number" id="priority" name="priority" min="0" max="1000" required value="{{ old('priority', $campaign->priority ?? 10) }}">
|
||||
<p class="muted">Si plusieurs campagnes en cours concernent le même agent, la priorité la plus haute s'affiche.</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Destinataires</legend>
|
||||
<p class="muted" style="margin:0 0 6px">Aucune case cochée : tous les agents. Sinon, seuls les membres des groupes cochés.</p>
|
||||
<div class="scroll">
|
||||
@forelse ($groups as $g)
|
||||
<label class="check">
|
||||
<input type="checkbox" name="groups[]" value="{{ $g->id }}" @checked(in_array($g->id, $checked, true))>{{ $g->display_name }}
|
||||
<span class="muted">({{ $g->members_count }})</span>
|
||||
</label>
|
||||
@empty
|
||||
<span class="muted">Aucun groupe synchronisé.</span>
|
||||
@endforelse
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if ($isAdmin)
|
||||
<input type="hidden" name="active" value="0">
|
||||
<label class="check" style="margin-top:14px">
|
||||
<input type="checkbox" name="active" value="1" @checked(old('active', $campaign->active))>Campagne active
|
||||
</label>
|
||||
<p class="muted">Décochée, la bannière n'est jamais insérée, même pendant la période.</p>
|
||||
@else
|
||||
<p class="muted" style="margin-top:14px">La campagne reste inactive tant qu'un administrateur ne l'a pas activée.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn" type="submit">{{ $campaign->exists ? 'Enregistrer' : 'Créer la campagne' }}</button>
|
||||
<a class="btn secondary" href="{{ route('admin.campaigns.index') }}">Annuler</a>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@if ($campaign->exists)
|
||||
<div class="card" style="margin-top:16px">
|
||||
<h2>Rendu enregistré</h2>
|
||||
<p class="muted">Tel qu'inséré à l'emplacement <code>{{ '{'.'{'.'{banniere}'.'}'.'}' }}</code> du template. Pour le voir dans une signature complète, ouvrez un agent dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
|
||||
@include('partials.signature-frame', ['html' => $bannerHtml, 'title' => 'Aperçu de la bannière'])
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const select = document.getElementById('image_path');
|
||||
const file = document.getElementById('image_file');
|
||||
const thumb = document.getElementById('thumb');
|
||||
const width = document.getElementById('width');
|
||||
|
||||
const show = (src) => { thumb.src = src; thumb.hidden = !src; };
|
||||
const fromLibrary = () => {
|
||||
const opt = select.selectedOptions[0];
|
||||
show(opt && opt.dataset.src ? opt.dataset.src : '');
|
||||
const natural = opt ? parseInt(opt.dataset.width, 10) : NaN;
|
||||
width.placeholder = natural ? `auto (${Math.min(natural, 600)})` : 'auto';
|
||||
};
|
||||
|
||||
select.addEventListener('change', () => { file.value = ''; fromLibrary(); });
|
||||
file.addEventListener('change', () => {
|
||||
if (!file.files.length) return fromLibrary();
|
||||
select.value = '';
|
||||
width.placeholder = 'auto';
|
||||
show(URL.createObjectURL(file.files[0]));
|
||||
});
|
||||
fromLibrary();
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@@ -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
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<h1 style="margin:0">Campagnes</h1>
|
||||
<span class="spacer"></span>
|
||||
<a class="btn" href="{{ route('admin.campaigns.create') }}">Nouvelle campagne</a>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Une campagne ajoute une bannière sous la signature pendant une période, pour tous les agents ou pour certains groupes AD.
|
||||
<strong>Une seule bannière par signature</strong> : 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 <code>{{ $variable }}</code>.
|
||||
@unless ($isAdmin) Vous préparez les campagnes ; un administrateur les active. @endunless
|
||||
</p>
|
||||
|
||||
@if ($withoutBanner->isNotEmpty())
|
||||
<div class="notice" role="note">
|
||||
Ces templates publiés ne contiennent pas <code>{{ $variable }}</code> : leurs agents ne verront aucune bannière —
|
||||
{{ $withoutBanner->join(', ') }}.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card" style="overflow-x:auto">
|
||||
<table class="list">
|
||||
<thead><tr><th style="width:130px">Bannière</th><th>Campagne</th><th>Période</th><th>Destinataires</th><th>Priorité</th><th>État</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($campaigns as $c)
|
||||
@php
|
||||
$status = $c->status();
|
||||
$locked = $c->active && ! $isAdmin;
|
||||
@endphp
|
||||
<tr>
|
||||
<td>
|
||||
@if (in_array($c->id, $missing, true))
|
||||
<span class="badge warn">image manquante</span>
|
||||
<div class="muted">{{ $c->image_path }}</div>
|
||||
@else
|
||||
<img src="{{ asset('img/'.$c->image_path) }}" alt="{{ $c->alt_text }}" style="max-width:120px;max-height:60px;border:1px solid var(--line);background:#fff">
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('admin.campaigns.edit', $c) }}"><strong>{{ $c->name }}</strong></a>
|
||||
<div class="muted">
|
||||
@if ($c->link_url)
|
||||
Lien : <a href="{{ $c->link_url }}" target="_blank" rel="noopener">{{ \Illuminate\Support\Str::limit($c->link_url, 50) }}</a>
|
||||
@else
|
||||
Sans lien
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td style="white-space:nowrap">{{ $period($c->starts_at) }}<br>→ {{ $period($c->ends_at) }}</td>
|
||||
<td style="overflow-wrap:anywhere">{{ $c->groups->isEmpty() ? 'Tous les agents' : $c->groups->pluck('display_name')->join(', ') }}</td>
|
||||
<td>{{ $c->priority }}</td>
|
||||
<td>
|
||||
<span class="badge {{ $badge[$status] }}">{{ \App\Models\Campaign::STATUSES[$status] }}</span>
|
||||
@if ($status === 'running')
|
||||
<div class="muted">
|
||||
@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
|
||||
</div>
|
||||
@elseif ($status === 'inactive')
|
||||
<div class="muted">{{ $isAdmin ? 'à activer' : 'en attente d\'un administrateur' }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<a class="btn secondary small" style="margin:2px 0" href="{{ route('admin.campaigns.edit', $c) }}">{{ $locked ? 'Voir' : 'Modifier' }}</a>
|
||||
@if ($isAdmin)
|
||||
@if ($status !== 'ended')
|
||||
<form class="inline" method="post" action="{{ route('admin.campaigns.toggle', $c) }}">
|
||||
@csrf
|
||||
<button class="btn secondary small" style="margin:2px 0" type="submit">{{ $c->active ? 'Désactiver' : 'Activer' }}</button>
|
||||
</form>
|
||||
@endif
|
||||
<form class="inline" method="post" action="{{ route('admin.campaigns.destroy', $c) }}"
|
||||
onsubmit="return confirm('Supprimer cette campagne ? Les messages déjà envoyés conservent la bannière.')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="btn danger small" style="margin:2px 0" type="submit">Supprimer</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="7" class="muted">Aucune campagne. Les signatures sont envoyées sans bannière.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Vérifiez la bannière reçue par un agent précis dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -26,7 +26,7 @@
|
||||
<div><div class="stat">{{ $groups->count() }}</div><div class="muted">groupes signature_*</div></div>
|
||||
<div><div class="stat"><a href="{{ route('admin.templates.index') }}">{{ $templates }}</a></div><div class="muted">templates</div></div>
|
||||
<div><div class="stat">{{ $assignments }}</div><div class="muted">affectations</div></div>
|
||||
<div><div class="stat">{{ $campaigns }}</div><div class="muted">campagnes en cours</div></div>
|
||||
<div><div class="stat"><a href="{{ route('admin.campaigns.index') }}">{{ $campaigns }}</a></div><div class="muted">campagnes en cours</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
||||
@@ -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 @@
|
||||
<a href="{{ route('admin.dashboard') }}" @class(['active' => request()->routeIs('admin.dashboard')])>Tableau de bord</a>
|
||||
<a href="{{ route('admin.templates.index') }}" @class(['active' => request()->routeIs('admin.templates.*')])>Templates</a>
|
||||
<a href="{{ route('admin.images.index') }}" @class(['active' => request()->routeIs('admin.images.*')])>Images</a>
|
||||
<a href="{{ route('admin.campaigns.index') }}" @class(['active' => request()->routeIs('admin.campaigns.*')])>Campagnes</a>
|
||||
@if (auth()->user()->hasRole('admin'))
|
||||
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
|
||||
<a href="{{ route('admin.fields.index') }}" @class(['active' => request()->routeIs('admin.fields.*')])>Champs agents</a>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Campaign;
|
||||
use App\Models\DirectoryGroup;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Template;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminCampaignsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
private DirectoryGroup $group;
|
||||
private string $dir;
|
||||
|
||||
// PNG 1×1 valide
|
||||
private const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
private const OIDS = ['editor' => '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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('<a href="https://www.beziers-mediterranee.fr/voeux?a=1&b=2"', $html);
|
||||
$this->assertSame(1, substr_count($html, '<img'));
|
||||
|
||||
// Pas de bannière dans la signature courte des réponses.
|
||||
$this->signature(['context' => 'reply'])->assertJson(['html' => 'd Jean']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Signature\CampaignSelector;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CampaignSelectorTest extends TestCase
|
||||
{
|
||||
private array $campaigns = [
|
||||
['id' => 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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user