feat(admin): CRUD menus composes avec slots (P3, mlt 8.4-8.6)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 46s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 52s
CI / auto-merge (pull_request) Successful in 6s
CI / auto-merge (push) Has been skipped

Quatrieme CRUD back-office, cas le plus riche du catalogue : un menu = burger de
base + N slots de composition (menu_slot) chacun proposant M options eligibles
(menu_slot_option), avec tarification Normal/Maxi.

- App\Catalogue\MenuRepository : all/find, slotsWithOptions (LEFT JOIN regroupe),
  create + update dans UNE transaction (RG-T08) ; update reconstruit les slots en
  delete-and-reinsert (mlt 8.5 RG-2) ; delete FK-safe (order_item.menu_id RESTRICT,
  CASCADE vers slots/options) ; setActive ; categoryExists/productExists/isReferencedByOrders.
- App\Controllers\MenuController : index/create/store/edit/update/toggle/confirmDelete/
  destroy ; guard menu.read/create/update/delete (RG-T03) ; CSRF sur les mutations
  (RG-T01) ; validation bornee + allowlist (RG-T16/T18). PIN equipier + audit_log
  UNIQUEMENT a la suppression (mlt 8.6, RG-T13/T14) ; create/update sans PIN (un
  menu n'a pas de vat_rate). Throttle PIN RG-T22 (gate-before-verify, echec PIN
  trace + compte dans UNE transaction).
- Vues admin/menus/{index,form,delete} + assets/js/menu-form.js : builder de slots
  vanilla JS (CSP 'self' : donnees via data-*, etat serialise en champ cache
  slots_json car Request::formBody ne garde que les scalaires).
- Routes /admin/menus dans le front controller ; lien nav Menus reactive (menu.read).
- Tests : MenuControllerTest (12 cas : guard, slots, CSRF, toggle, flux PIN+audit
  atomique sur delete, 422 FK) ; MenuRepositoryDbTest (integration, vraie DB :
  create+slots, slotsWithOptions, update delete-and-reinsert, delete+cascade) ;
  FakeDatabase etendu (menu).

Suite : 201 tests verts (597 assertions avec WAKDO_DB_TESTS=1), PHPStan L6 propre.
This commit is contained in:
Imugiii 2026-06-16 13:17:08 +00:00
parent 7d24714d9b
commit 26672b1467
11 changed files with 1724 additions and 3 deletions

View file

@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
use App\Core\DatabaseInterface;
/**
* Acces aux donnees du menu compose (sous-domaine Catalogue) : la ligne `menu`,
* ses `menu_slot` (slots de composition) et les `menu_slot_option` (produits
* eligibles par slot). Suit le pattern de CategoryRepository / ProductRepository.
*
* Topologie FK (db/migrations/0001) et effet sur la suppression :
* - menu.category_id / menu.burger_product_id : RESTRICT (referencent catalogue).
* - menu_slot.menu_id : CASCADE (slots possedes par le menu).
* - menu_slot_option.menu_slot_id : CASCADE ; .product_id : RESTRICT.
* - order_item.menu_id : RESTRICT -> la suppression dure est bloquee si le menu
* est reference par une commande historique (mlt 8.6 RG-1 : le controleur
* traduit la violation en 422 et propose la desactivation).
*
* create() et update() ecrivent menu + slots + options dans UNE transaction
* (RG-T08). update() reconstruit les slots en delete-and-reinsert (mlt 8.5 RG-2).
*/
final class MenuRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* Liste pour le back-office, avec le libelle de categorie et le nom du burger.
*
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->db->fetchAll(
'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.price_normal_cents, '
. 'm.price_maxi_cents, m.is_available, m.display_order, '
. 'c.name AS category_name, p.name AS burger_name '
. 'FROM menu m '
. 'JOIN category c ON c.id = m.category_id '
. 'JOIN product p ON p.id = m.burger_product_id '
. 'ORDER BY m.display_order, m.name',
);
}
/**
* @return array<string, mixed>|null
*/
public function find(int $id): ?array
{
return $this->db->fetch(
'SELECT id, category_id, burger_product_id, name, price_normal_cents, '
. 'price_maxi_cents, is_available, display_order FROM menu WHERE id = :id',
['id' => $id],
);
}
/**
* Slots d'un menu (ordonnes), chacun avec la liste de ses product_id eligibles.
* Une seule requete (LEFT JOIN) regroupee en PHP par slot.
*
* @return list<array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}>
*/
public function slotsWithOptions(int $menuId): array
{
$rows = $this->db->fetchAll(
'SELECT s.id, s.name, s.slot_type, s.is_required, s.display_order, o.product_id '
. 'FROM menu_slot s '
. 'LEFT JOIN menu_slot_option o ON o.menu_slot_id = s.id '
. 'WHERE s.menu_id = :id ORDER BY s.display_order, s.id',
['id' => $menuId],
);
/** @var array<int, array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}> $slots */
$slots = [];
foreach ($rows as $r) {
$sid = (int) ($r['id'] ?? 0);
if (!isset($slots[$sid])) {
$slots[$sid] = [
'id' => $sid,
'name' => (string) ($r['name'] ?? ''),
'slot_type' => (string) ($r['slot_type'] ?? ''),
'is_required' => (int) ($r['is_required'] ?? 0),
'display_order' => (int) ($r['display_order'] ?? 0),
'option_product_ids' => [],
];
}
if (($r['product_id'] ?? null) !== null) {
$slots[$sid]['option_product_ids'][] = (int) $r['product_id'];
}
}
return array_values($slots);
}
public function categoryExists(int $id): bool
{
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $id]) !== null;
}
public function productExists(int $id): bool
{
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
}
/**
* Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une
* ligne de commande historique ? La FK order_item.menu_id est RESTRICT.
*/
public function isReferencedByOrders(int $id): bool
{
return $this->db->fetch('SELECT menu_id FROM order_item WHERE menu_id = :id LIMIT 1', ['id' => $id]) !== null;
}
/**
* Cree le menu et sa configuration de slots dans UNE transaction (mlt 8.4 RG-2).
* Retourne l'id du menu cree.
*
* @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data
* @param list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $slots
*/
public function create(array $data, array $slots): int
{
$newId = 0;
$this->db->transaction(function (DatabaseInterface $db) use ($data, $slots, &$newId): void {
$db->execute(
'INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, '
. 'price_maxi_cents, is_available, display_order) '
. 'VALUES (:category, :burger, :name, :pnormal, :pmaxi, :available, :ord)',
$this->bindMenu($data),
);
$newId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
$this->insertSlots($db, $newId, $slots);
});
return $newId;
}
/**
* Met a jour le menu et RECONSTRUIT ses slots (delete-and-reinsert, mlt 8.5
* RG-2) dans UNE transaction : un edit de la configuration de slots est plus
* simple et sur a re-poser entierement qu'a reconcilier en place.
*
* @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data
* @param list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $slots
*/
public function update(int $id, array $data, array $slots): void
{
$this->db->transaction(function (DatabaseInterface $db) use ($id, $data, $slots): void {
$db->execute(
'UPDATE menu SET category_id = :category, burger_product_id = :burger, name = :name, '
. 'price_normal_cents = :pnormal, price_maxi_cents = :pmaxi, is_available = :available, '
. 'display_order = :ord WHERE id = :id',
$this->bindMenu($data) + ['id' => $id],
);
// Options d'abord (FK vers slot), puis slots, puis re-insertion.
$db->execute(
'DELETE FROM menu_slot_option WHERE menu_slot_id IN '
. '(SELECT id FROM menu_slot WHERE menu_id = :id)',
['id' => $id],
);
$db->execute('DELETE FROM menu_slot WHERE menu_id = :id', ['id' => $id]);
$this->insertSlots($db, $id, $slots);
});
}
/**
* Suppression dure. CASCADE retire menu_slot + menu_slot_option ;
* order_item.menu_id (RESTRICT) bloque si une commande historique reference le
* menu (le controleur attrape SQLSTATE 23000 -> 422).
*/
public function delete(int $id): int
{
return $this->db->execute('DELETE FROM menu WHERE id = :id', ['id' => $id]);
}
public function setActive(int $id, bool $active): int
{
return $this->db->execute(
'UPDATE menu SET is_available = :a WHERE id = :id',
['a' => $active ? 1 : 0, 'id' => $id],
);
}
/**
* Insere les slots d'un menu et leurs options (helper partage create/update).
*
* @param list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $slots
*/
private function insertSlots(DatabaseInterface $db, int $menuId, array $slots): void
{
foreach ($slots as $slot) {
$db->execute(
'INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) '
. 'VALUES (:menu, :name, :type, :required, :ord)',
[
'menu' => $menuId,
'name' => $slot['name'],
'type' => $slot['slot_type'],
'required' => $slot['is_required'],
'ord' => $slot['display_order'],
],
);
$slotId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
foreach ($slot['options'] as $productId) {
$db->execute(
'INSERT INTO menu_slot_option (menu_slot_id, product_id) VALUES (:slot, :product)',
['slot' => $slotId, 'product' => $productId],
);
}
}
}
/**
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
*
* @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data
* @return array<string, mixed>
*/
private function bindMenu(array $data): array
{
return [
'category' => $data['category_id'],
'burger' => $data['burger_product_id'],
'name' => $data['name'],
'pnormal' => $data['price_normal_cents'],
'pmaxi' => $data['price_maxi_cents'],
'available' => $data['is_available'],
'ord' => $data['display_order'],
];
}
}

View file

@ -0,0 +1,535 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use PDOException;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Catalogue\CategoryRepository;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* CRUD des menus composes (P3, mlt 8.4-8.6). Un menu = ligne `menu` + ses
* `menu_slot` (slots de composition) + `menu_slot_option` (produits eligibles).
*
* - create (menu.create) / update (menu.update) : SANS PIN (un menu n'a pas de
* vat_rate ; la sensibilite fiscale est au niveau composant -> hors RG-T13) ;
* - delete (menu.delete) : action sensible -> PIN equipier + audit (RG-T13/T14,
* mlt 8.6), suppression dure seulement si non reference par order_item.menu_id
* (FK RESTRICT -> 422 sinon, proposer la desactivation).
*
* La configuration de slots est soumise en un champ cache `slots_json` (le
* builder vanilla JS la serialise) : Request::formBody() ne retient que les
* scalaires, donc une structure imbriquee passe par du JSON valide cote serveur.
*
* Non `final` : les tests sous-classent pour injecter des doubles.
*/
class MenuController extends AdminController
{
private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('menu.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/menus/index', [
'title' => 'Menus - Wakdo Admin',
'activeNav' => 'menus',
'menus' => $this->menuRepository()->all(),
], $guard);
}
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('menu.create');
if ($guard instanceof Response) {
return $guard;
}
return $this->renderForm($guard, 0, [], [], []);
}
/**
* @param array<string, string> $params
*/
public function store(array $params = []): Response
{
$guard = $this->guard('menu.create');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
[$data, $slots, $errors] = $this->validate($form);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $slots, $errors, 422);
}
$this->menuRepository()->create($data, $slots);
$this->setFlash('Menu cree.');
return $this->redirect('/admin/menus');
}
/**
* @param array<string, string> $params
*/
public function edit(array $params): Response
{
$guard = $this->guard('menu.update');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$menu = $this->menuRepository()->find($id);
if ($menu === null) {
return $this->notFound($guard);
}
$slots = $this->menuRepository()->slotsWithOptions($id);
return $this->renderForm($guard, $id, $menu, $this->slotsToForm($slots), []);
}
/**
* @param array<string, string> $params
*/
public function update(array $params): Response
{
$guard = $this->guard('menu.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
if ($this->menuRepository()->find($id) === null) {
return $this->notFound($guard);
}
[$data, $slots, $errors] = $this->validate($form);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form, $slots, $errors, 422);
}
$this->menuRepository()->update($id, $data, $slots);
$this->setFlash('Menu mis a jour.');
return $this->redirect('/admin/menus');
}
/**
* @param array<string, string> $params
*/
public function toggle(array $params): Response
{
$guard = $this->guard('menu.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$menu = $this->menuRepository()->find($id);
if ($menu === null) {
return $this->notFound($guard);
}
$this->menuRepository()->setActive($id, (int) ($menu['is_available'] ?? 0) !== 1);
$this->setFlash('Disponibilite du menu mise a jour.');
return $this->redirect('/admin/menus');
}
/**
* @param array<string, string> $params
*/
public function confirmDelete(array $params): Response
{
$guard = $this->guard('menu.delete');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$menu = $this->menuRepository()->find($id);
if ($menu === null) {
return $this->notFound($guard);
}
return $this->renderDelete($guard, $id, $menu, null);
}
/**
* @param array<string, string> $params
*/
public function destroy(array $params): Response
{
$guard = $this->guard('menu.delete');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$menu = $this->menuRepository()->find($id);
if ($menu === null) {
return $this->notFound($guard);
}
// RG-T22 : verrou de throttle PIN par utilisateur AGISSANT (session), evalue
// AVANT la verification ; un acteur verrouille recoit le meme 422 generique,
// on paie un leurre de timing et on n'ecrit pas de pin.failed sous verrou.
$actorId = $guard->userId ?? 0;
if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) {
$this->pinVerifier()->payTimingDecoy($form['pin'] ?? '');
return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).');
}
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
// RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans
// UNE meme transaction (pas d'etat partiel si crash entre les deux).
$email = trim($form['pin_email'] ?? '');
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void {
$this->logFailedPin($db, $email, $id);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).');
}
$name = (string) ($menu['name'] ?? '');
// FK order_item.menu_id RESTRICT -> PDOException 23000 -> 422 (catch).
// menu_slot / menu_slot_option sont CASCADE (supprimes avec le menu).
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void {
$deleted = (new MenuRepository($db))->delete($id);
if ($deleted === 1) {
$this->writeAudit($db, 'menu.delete', $actor['id'], $actor['role_id'], $id, 'Suppression menu: ' . $name);
}
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderDelete($guard, $id, $menu, 'Menu reference par des commandes : suppression impossible. Desactivez-le plutot.');
}
throw $exception;
}
// PIN valide + suppression effective : reset du compteur de l'acteur de
// SESSION (RG-T22, cle = $actorId, pas l'acteur resolu par le PIN).
$this->pinThrottle()->reset($actorId);
$this->setFlash('Menu supprime.');
return $this->redirect('/admin/menus');
}
protected function menuRepository(): MenuRepository
{
return new MenuRepository($this->db());
}
protected function productRepository(): ProductRepository
{
return new ProductRepository($this->db());
}
protected function categoryRepository(): CategoryRepository
{
return new CategoryRepository($this->db());
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
}
protected function pinThrottle(): PinThrottle
{
return new PinThrottle($this->db(), $this->config);
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
/**
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees menu,
* slots normalises, erreurs]. Les slots viennent du champ cache slots_json.
*
* @param array<string, string> $form
* @return array{0: array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int}, 1: list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}>, 2: array<string, string>}
*/
private function validate(array $form): array
{
$errors = [];
$categoryRaw = trim($form['category_id'] ?? '');
$categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0;
if ($categoryId === 0 || !$this->menuRepository()->categoryExists($categoryId)) {
$errors['category_id'] = 'Categorie requise et valide.';
}
$burgerRaw = trim($form['burger_product_id'] ?? '');
$burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0;
if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) {
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.';
}
$name = trim($form['name'] ?? '');
if ($name === '' || mb_strlen($name) > 120) {
$errors['name'] = 'Le nom est requis (120 caracteres max).';
}
$priceNormal = $this->parsePrice($form['price_normal_cents'] ?? '');
if ($priceNormal === null) {
$errors['price_normal_cents'] = 'Le prix Normal (centimes) doit etre un entier strictement positif.';
}
$priceMaxi = $this->parsePrice($form['price_maxi_cents'] ?? '');
if ($priceMaxi === null) {
$errors['price_maxi_cents'] = 'Le prix Maxi (centimes) doit etre un entier strictement positif.';
}
$orderRaw = trim($form['display_order'] ?? '0');
$displayOrder = ctype_digit($orderRaw) && (int) $orderRaw <= 65535 ? (int) $orderRaw : -1;
if ($displayOrder < 0) {
$errors['display_order'] = 'L\'ordre d\'affichage doit etre un entier entre 0 et 65535.';
}
$slots = $this->parseSlots($form['slots_json'] ?? '', $errors);
$data = [
'category_id' => $categoryId,
'burger_product_id' => $burgerId,
'name' => $name,
'price_normal_cents' => $priceNormal ?? 0,
'price_maxi_cents' => $priceMaxi ?? 0,
'is_available' => isset($form['is_available']) ? 1 : 0,
'display_order' => $displayOrder < 0 ? 0 : $displayOrder,
];
return [$data, $slots, $errors];
}
/**
* Decode + valide la configuration de slots soumise en JSON. Precondition
* mlt 8.4 : >=1 slot avec >=1 option ; chaque option doit exister.
*
* @param array<string, string> $errors
* @return list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}>
*/
private function parseSlots(string $json, array &$errors): array
{
if (trim($json) === '') {
$errors['slots'] = 'Au moins un slot avec au moins une option est requis.';
return [];
}
/** @var mixed $decoded */
$decoded = json_decode($json, true);
if (!is_array($decoded) || $decoded === []) {
$errors['slots'] = 'Configuration de slots invalide.';
return [];
}
$slots = [];
$order = 0;
foreach ($decoded as $raw) {
if (!is_array($raw)) {
continue;
}
$slotName = is_string($raw['name'] ?? null) ? trim($raw['name']) : '';
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
$required = !empty($raw['is_required']) ? 1 : 0;
$optionIds = [];
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
$pid = is_numeric($opt) ? (int) $opt : 0;
if ($pid > 0 && $this->menuRepository()->productExists($pid)) {
$optionIds[] = $pid;
}
}
$optionIds = array_values(array_unique($optionIds));
if ($slotName === '' || mb_strlen($slotName) > 80) {
$errors['slots'] = 'Chaque slot doit avoir un nom (80 caracteres max).';
continue;
}
if (!in_array($slotType, self::SLOT_TYPES, true)) {
$errors['slots'] = 'Type de slot invalide.';
continue;
}
if ($optionIds === []) {
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
continue;
}
$slots[] = [
'name' => $slotName,
'slot_type' => $slotType,
'is_required' => $required,
'display_order' => $order++,
'options' => $optionIds,
];
}
if ($slots === [] && !isset($errors['slots'])) {
$errors['slots'] = 'Au moins un slot avec au moins une option est requis.';
}
return $slots;
}
private function parsePrice(string $raw): ?int
{
$raw = trim($raw);
return ctype_digit($raw) && (int) $raw > 0 && (int) $raw <= 4294967295 ? (int) $raw : null;
}
/**
* Transforme les slots charges (repository) en structure JSON pour pre-remplir
* le builder a l'edition.
*
* @param list<array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}> $slots
* @return list<array{name:string, slot_type:string, is_required:int, options:list<int>}>
*/
private function slotsToForm(array $slots): array
{
return array_map(static fn (array $s): array => [
'name' => $s['name'],
'slot_type' => $s['slot_type'],
'is_required' => $s['is_required'],
'options' => $s['option_product_ids'],
], $slots);
}
/**
* @param array<string, mixed> $values valeurs du menu (re-rendu) ou row trouvee
* @param list<array<string, mixed>> $slots slots pre-remplis (structure JSON)
* @param array<string, string> $errors
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $slots, array $errors, int $status = 200): Response
{
return $this->adminView('admin/menus/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' menu - Wakdo Admin',
'activeNav' => 'menus',
'menuId' => $id,
'categories' => $this->categoryRepository()->all(),
'products' => $this->productRepository()->all(),
'slotTypes' => self::SLOT_TYPES,
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
'name' => (string) ($values['name'] ?? ''),
'price_normal_cents' => (string) ($values['price_normal_cents'] ?? ''),
'price_maxi_cents' => (string) ($values['price_maxi_cents'] ?? ''),
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
'display_order' => (string) ($values['display_order'] ?? '0'),
],
'slotsJson' => json_encode($slots, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
'errors' => $errors,
], $guard, $status);
}
/**
* @param array<string, mixed> $menu
*/
private function renderDelete(GuardResult $guard, int $id, array $menu, ?string $error): Response
{
return $this->adminView('admin/menus/delete', [
'title' => 'Supprimer un menu - Wakdo Admin',
'activeNav' => 'menus',
'menuId' => $id,
'name' => (string) ($menu['name'] ?? ''),
'error' => $error,
], $guard, $error !== null ? 422 : 200);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'menus'], $guard, 404);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
/**
* Trace une tentative de PIN echouee sur une action sensible (RG-T14), acteur
* inconnu (PIN non resolu). Recoit le $db de la transaction (atomicite RG-T08).
*/
private function logFailedPin(DatabaseInterface $db, string $email, int $menuId): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => null,
'rid' => null,
'code' => 'pin.failed',
'etype' => 'menu',
'eid' => $menuId,
'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')',
],
);
}
private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
['uid' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'menu', 'eid' => $entityId, 'summary' => $summary],
);
}
}

View file

@ -96,7 +96,7 @@ $navClass = static function (string $code, string $current): string {
<a href="/admin/dashboard" class="<?= $navClass('dashboard', $active) ?>">Tableau de bord</a> <a href="/admin/dashboard" class="<?= $navClass('dashboard', $active) ?>">Tableau de bord</a>
</div> </div>
<?php if ($can('category.manage') || $can('product.read')): ?> <?php if ($can('category.manage') || $can('product.read') || $can('menu.read')): ?>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-label">Catalogue</div> <div class="sidebar-section-label">Catalogue</div>
<?php if ($can('category.manage')): ?> <?php if ($can('category.manage')): ?>
@ -105,14 +105,17 @@ $navClass = static function (string $code, string $current): string {
<?php if ($can('product.read')): ?> <?php if ($can('product.read')): ?>
<a href="/admin/products" class="<?= $navClass('products', $active) ?>">Produits</a> <a href="/admin/products" class="<?= $navClass('products', $active) ?>">Produits</a>
<?php endif; ?> <?php endif; ?>
<?php if ($can('menu.read')): ?>
<a href="/admin/menus" class="<?= $navClass('menus', $active) ?>">Menus</a>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php /* <?php /*
Items de nav volontairement absents tant que leur page n'existe pas Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver (un lien vers une route non enregistree renvoie un 404). A reactiver
avec leur route respective : Menus (menu.read), Commandes (order.read), avec leur route respective : Commandes (order.read), Utilisateurs
Utilisateurs (user.read), Roles (role.manage) -- P3 suite / P4. (user.read), Roles (role.manage) -- P3 suite / P4.
*/ ?> */ ?>
</nav> </nav>

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* Confirmation de suppression d'un menu (action sensible RG-T13/mlt 8.6) : exige
* l'email + le PIN de l'equipier. La suppression cascade vers menu_slot /
* menu_slot_option ; bloquee (422) si reference par une commande historique.
* Injecte dans admin/layout.php.
*
* @var int $menuId
* @var string $name
* @var string|null $error
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($menuId ?? 0);
$menuName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8');
$errorMessage = isset($error) && is_string($error) ? $error : null;
?>
<div class="page-header">
<div>
<h1 class="page-title">Supprimer un menu</h1>
<p class="page-subtitle">Confirmez la suppression de "<?= $menuName ?>".</p>
</div>
</div>
<section>
<?php if ($errorMessage !== null): ?>
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/admin/menus/<?= $id ?>/delete" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>La suppression est tracee (audit) et retire aussi les slots du menu. Renseignez votre email et votre PIN.</small></p>
<div class="form-group">
<label class="form-label" for="pin_email">Votre email</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off" required>
</div>
<div class="form-group">
<label class="form-label" for="pin">Votre PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Supprimer definitivement</button>
<a class="btn btn-secondary" href="/admin/menus">Annuler</a>
</div>
</form>
</section>

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* Formulaire menu (creation/edition), injecte dans admin/layout.php. Reaffiche
* valeurs + erreurs (RG-T18). La composition de slots est geree par le builder
* vanilla JS (menu-form.js) qui serialise l'etat dans le champ cache slots_json
* a la soumission. Pas de PIN ici (create/update non sensibles, mlt 8.4/8.5).
*
* @var int $menuId
* @var array<int, array<string, mixed>> $categories
* @var array<int, array<string, mixed>> $products
* @var list<string> $slotTypes
* @var array<string, mixed> $values
* @var string $slotsJson
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($menuId ?? 0);
$action = $id !== 0 ? '/admin/menus/' . $id : '/admin/menus';
/** @var array<string, mixed> $vals */
$vals = isset($values) && is_array($values) ? $values : [];
/** @var array<string, string> $errs */
$errs = isset($errors) && is_array($errors) ? $errors : [];
/** @var array<int, array<string, mixed>> $cats */
$cats = isset($categories) && is_array($categories) ? $categories : [];
/** @var array<int, array<string, mixed>> $prods */
$prods = isset($products) && is_array($products) ? $products : [];
/** @var list<string> $types */
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
$selectedCat = (string) ($vals['category_id'] ?? '');
$selectedBurger = (string) ($vals['burger_product_id'] ?? '');
$available = (bool) ($vals['is_available'] ?? true);
// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de
// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut.
$slimProducts = array_map(
static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')],
$prods,
);
$attr = static fn (mixed $data): string => htmlspecialchars(
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
ENT_QUOTES,
'UTF-8',
);
$slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $slotsJson : '[]';
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le menu' : 'Nouveau menu' ?></h1>
</div>
</div>
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card" id="menu-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="category_id">Categorie</label>
<select class="form-input" id="category_id" name="category_id" required>
<option value="">-- choisir --</option>
<?php foreach ($cats as $cat): ?>
<?php $cid = (string) ($cat['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($cid, ENT_QUOTES, 'UTF-8') ?>"<?= $cid === $selectedCat ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($cat['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($err('category_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('category_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="burger_product_id">Burger de base</label>
<select class="form-input" id="burger_product_id" name="burger_product_id" required>
<option value="">-- choisir --</option>
<?php foreach ($prods as $p): ?>
<?php $pid = (string) ($p['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"<?= $pid === $selectedBurger ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($p['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($err('burger_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('burger_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="name">Nom</label>
<input class="form-input" type="text" id="name" name="name" maxlength="120" value="<?= $val('name') ?>" required>
<?php if ($err('name') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('name'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="price_normal_cents">Prix Normal (en centimes)</label>
<input class="form-input" type="number" id="price_normal_cents" name="price_normal_cents" min="1" value="<?= $val('price_normal_cents') ?>" required>
<?php if ($err('price_normal_cents') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('price_normal_cents'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="price_maxi_cents">Prix Maxi (en centimes)</label>
<input class="form-input" type="number" id="price_maxi_cents" name="price_maxi_cents" min="1" value="<?= $val('price_maxi_cents') ?>" required>
<?php if ($err('price_maxi_cents') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('price_maxi_cents'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="display_order">Ordre d'affichage</label>
<input class="form-input" type="number" id="display_order" name="display_order" min="0" value="<?= $val('display_order') ?>">
<?php if ($err('display_order') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('display_order'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
</div>
<fieldset class="form-group">
<legend>Slots de composition</legend>
<p><small>Au moins un slot, chacun avec au moins une option. Les choix proposes au client par slot.</small></p>
<?php if ($err('slots') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('slots'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<div id="slot-builder"
data-products="<?= $attr($slimProducts) ?>"
data-slot-types="<?= $attr($types) ?>"
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
</fieldset>
<input type="hidden" name="slots_json" id="slots_json" value="">
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer</button>
<a class="btn btn-secondary" href="/admin/menus">Annuler</a>
</div>
</form>
<script src="/assets/js/menu-form.js"></script>

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Liste des menus (CRUD admin), injectee dans admin/layout.php. Texte echappe.
* Le toggle de disponibilite est un POST CSRF (pas de JS).
*
* @var array<int, array<string, mixed>> $menus
* @var string $csrfToken
*/
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($menus) && is_array($menus) ? $menus : [];
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR';
?>
<div class="page-header">
<div>
<h1 class="page-title">Menus</h1>
<p class="page-subtitle">Gestion des menus composes (burger + slots)</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/menus/new">Nouveau menu</a>
</div>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Nom</th>
<th>Categorie</th>
<th>Burger de base</th>
<th>Prix (Normal &ndash; Maxi)</th>
<th>Statut</th>
<th style="width:240px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun menu.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$available = (int) ($row['is_available'] ?? 0) === 1;
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['burger_name'] ?? '') ?></td>
<td><?= $esc($euros((int) ($row['price_normal_cents'] ?? 0))) ?> &ndash; <?= $esc($euros((int) ($row['price_maxi_cents'] ?? 0))) ?></td>
<td>
<?php if ($available): ?>
<span class="pill pill-success">Disponible</span>
<?php else: ?>
<span class="pill pill-neutral">Indisponible</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/menus/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/menus/<?= $id ?>/toggle" style="display:inline">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit"><?= $available ? 'Desactiver' : 'Activer' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/menus/<?= $id ?>/delete">Supprimer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,160 @@
/*
* menu-form.js Builder de slots du formulaire menu (back-office).
*
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
* slots initiaux) sont lues depuis les attributs data-* de #slot-builder.
* A la soumission, l'etat des slots est serialise en JSON dans le champ cache
* #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou
* le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
*/
(function () {
'use strict';
var builder = document.getElementById('slot-builder');
var form = document.getElementById('menu-form');
var hidden = document.getElementById('slots_json');
var addBtn = document.getElementById('add-slot');
if (!builder || !form || !hidden || !addBtn) {
return;
}
function parseData(key, fallback) {
try {
var v = JSON.parse(builder.dataset[key] || fallback);
return Array.isArray(v) ? v : JSON.parse(fallback);
} catch (e) {
return JSON.parse(fallback);
}
}
var products = parseData('products', '[]'); // [{id, name}]
var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...]
var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
function el(tag, className) {
var e = document.createElement(tag);
if (className) {
e.className = className;
}
return e;
}
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
function renderSlot(slot) {
slot = slot || {};
var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : [];
var block = el('fieldset', 'slot-block form-group');
block.style.border = '1px solid #ddd';
block.style.padding = '0.75rem';
block.style.marginBottom = '0.75rem';
var head = el('div');
// Nom du slot
var nameLabel = el('label');
nameLabel.appendChild(document.createTextNode('Nom du slot '));
var nameInput = el('input', 'form-input slot-name');
nameInput.type = 'text';
nameInput.maxLength = 80;
nameInput.value = slot.name ? String(slot.name) : '';
nameLabel.appendChild(nameInput);
head.appendChild(nameLabel);
// Type
var typeLabel = el('label');
typeLabel.appendChild(document.createTextNode(' Type '));
var typeSelect = el('select', 'form-input slot-type');
slotTypes.forEach(function (t) {
var opt = el('option');
opt.value = String(t);
opt.textContent = String(t);
if (String(slot.slot_type) === String(t)) {
opt.selected = true;
}
typeSelect.appendChild(opt);
});
typeLabel.appendChild(typeSelect);
head.appendChild(typeLabel);
// Requis
var reqLabel = el('label');
var reqInput = el('input', 'slot-required');
reqInput.type = 'checkbox';
if (Number(slot.is_required) === 1) {
reqInput.checked = true;
}
reqLabel.appendChild(reqInput);
reqLabel.appendChild(document.createTextNode(' Requis'));
head.appendChild(reqLabel);
// Retirer
var removeBtn = el('button', 'btn btn-secondary slot-remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
block.parentNode.removeChild(block);
});
head.appendChild(removeBtn);
block.appendChild(head);
// Options : cases a cocher des produits eligibles
var optWrap = el('div', 'slot-options');
optWrap.style.maxHeight = '160px';
optWrap.style.overflowY = 'auto';
optWrap.style.marginTop = '0.5rem';
products.forEach(function (p) {
var lab = el('label');
lab.style.display = 'block';
var cb = el('input', 'slot-option');
cb.type = 'checkbox';
cb.value = String(p.id);
if (selectedOptions.indexOf(Number(p.id)) !== -1) {
cb.checked = true;
}
lab.appendChild(cb);
lab.appendChild(document.createTextNode(' ' + String(p.name)));
optWrap.appendChild(lab);
});
block.appendChild(optWrap);
return block;
}
// Lit l'etat des blocs et le serialise dans #slots_json.
function serialize() {
var slots = [];
var blocks = builder.querySelectorAll('.slot-block');
Array.prototype.forEach.call(blocks, function (block) {
var name = block.querySelector('.slot-name').value.trim();
var type = block.querySelector('.slot-type').value;
var required = block.querySelector('.slot-required').checked ? 1 : 0;
var options = [];
Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) {
if (cb.checked) {
options.push(Number(cb.value));
}
});
slots.push({ name: name, slot_type: type, is_required: required, options: options });
});
hidden.value = JSON.stringify(slots);
}
addBtn.addEventListener('click', function () {
builder.appendChild(renderSlot(null));
});
form.addEventListener('submit', function () {
serialize();
});
// Rendu initial : slots existants (edition) ou un slot vide (creation).
if (initialSlots.length) {
initialSlots.forEach(function (s) {
builder.appendChild(renderSlot(s));
});
} else {
builder.appendChild(renderSlot(null));
}
})();

View file

@ -17,6 +17,7 @@ use App\Controllers\DashboardController;
use App\Controllers\HealthController; use App\Controllers\HealthController;
use App\Controllers\HomeController; use App\Controllers\HomeController;
use App\Controllers\MeController; use App\Controllers\MeController;
use App\Controllers\MenuController;
use App\Controllers\PasswordResetController; use App\Controllers\PasswordResetController;
use App\Controllers\ProductController; use App\Controllers\ProductController;
use App\Controllers\ProfileController; use App\Controllers\ProfileController;
@ -90,6 +91,19 @@ try {
$router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']);
$router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']);
// CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base +
// slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression
// (mlt 8.6) ; create/update sans PIN. {id} = un seul segment, pas de collision
// avec /toggle ni /delete.
$router->add('GET', '/admin/menus', [MenuController::class, 'index']);
$router->add('GET', '/admin/menus/new', [MenuController::class, 'create']);
$router->add('POST', '/admin/menus', [MenuController::class, 'store']);
$router->add('GET', '/admin/menus/{id}/edit', [MenuController::class, 'edit']);
$router->add('POST', '/admin/menus/{id}', [MenuController::class, 'update']);
$router->add('POST', '/admin/menus/{id}/toggle', [MenuController::class, 'toggle']);
$router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']);
$router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']);
$response = $router->dispatch(Request::fromGlobals()); $response = $router->dispatch(Request::fromGlobals());
$response->send(); $response->send();
} catch (Throwable $exception) { } catch (Throwable $exception) {

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Catalogue\MenuRepository;
use App\Core\Config;
use App\Core\Database;
/**
* CRUD reel de MenuRepository contre une vraie MariaDB (schema migre + seede),
* y compris la composition de slots et le delete-and-reinsert a l'update.
* Auto-skip si WAKDO_DB_TESTS != 1. Menu jetable (nom it-menu-*), nettoyage en
* tearDown (CASCADE retire slots + options).
*/
final class MenuRepositoryDbTest extends TestCase
{
private Database $db;
private string $name = '';
private int $categoryId = 0;
/** @var list<int> */
private array $productIds = [];
protected function setUp(): void
{
if (getenv('WAKDO_DB_TESTS') !== '1') {
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
}
$this->db = new Database(new Config());
try {
$this->db->fetch('SELECT 1');
} catch (Throwable $exception) {
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
}
$this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0);
$this->productIds = array_map(
static fn (array $r): int => (int) ($r['id'] ?? 0),
$this->db->fetchAll('SELECT id FROM product ORDER BY id LIMIT 3'),
);
$this->name = 'it-menu-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
if ($this->name !== '') {
// CASCADE menu -> menu_slot -> menu_slot_option.
$this->db->execute('DELETE FROM menu WHERE name = :name', ['name' => $this->name]);
}
}
public function testCreateFindUpdateSlotsAndDelete(): void
{
self::assertGreaterThan(0, $this->categoryId);
self::assertCount(3, $this->productIds);
[$burger, $optA, $optB] = $this->productIds;
$repo = new MenuRepository($this->db);
self::assertTrue($repo->categoryExists($this->categoryId));
self::assertTrue($repo->productExists($burger));
self::assertFalse($repo->productExists(0));
// --- create : menu + 2 slots (drink avec 2 options, side avec 1) ---
$id = $repo->create(
[
'category_id' => $this->categoryId,
'burger_product_id' => $burger,
'name' => $this->name,
'price_normal_cents' => 790,
'price_maxi_cents' => 990,
'is_available' => 1,
'display_order' => 50,
],
[
['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'options' => [$optA, $optB]],
['name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'options' => [$optA]],
],
);
self::assertGreaterThan(0, $id);
$found = $repo->find($id);
self::assertNotNull($found);
self::assertSame(790, (int) ($found['price_normal_cents'] ?? 0));
self::assertSame(990, (int) ($found['price_maxi_cents'] ?? 0));
$slots = $repo->slotsWithOptions($id);
self::assertCount(2, $slots);
self::assertSame('drink', $slots[0]['slot_type']);
self::assertEqualsCanonicalizing([$optA, $optB], $slots[0]['option_product_ids']);
self::assertSame('side', $slots[1]['slot_type']);
self::assertSame([$optA], $slots[1]['option_product_ids']);
// all() porte categorie + burger joints.
$names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all());
self::assertContains($this->name, $names);
self::assertFalse($repo->isReferencedByOrders($id));
// --- update : change le prix maxi ET reconfigure en 1 SEUL slot ---
// (verifie le delete-and-reinsert : les 2 anciens slots disparaissent).
$repo->update(
$id,
[
'category_id' => $this->categoryId,
'burger_product_id' => $burger,
'name' => $this->name,
'price_normal_cents' => 790,
'price_maxi_cents' => 1090,
'is_available' => 0,
'display_order' => 51,
],
[
['name' => 'Sauce', 'slot_type' => 'sauce', 'is_required' => 0, 'display_order' => 0, 'options' => [$optB]],
],
);
$updated = $repo->find($id);
self::assertNotNull($updated);
self::assertSame(1090, (int) ($updated['price_maxi_cents'] ?? 0));
self::assertSame(0, (int) ($updated['is_available'] ?? 1));
$slotsAfter = $repo->slotsWithOptions($id);
self::assertCount(1, $slotsAfter); // delete-and-reinsert : plus que 1 slot
self::assertSame('sauce', $slotsAfter[0]['slot_type']);
self::assertSame([$optB], $slotsAfter[0]['option_product_ids']);
// --- delete : menu non reference -> suppression dure OK, slots cascade ---
self::assertSame(1, $repo->delete($id));
self::assertNull($repo->find($id));
self::assertSame([], $repo->slotsWithOptions($id));
}
}

View file

@ -134,6 +134,30 @@ final class FakeDatabase implements DatabaseInterface
*/ */
public ?array $productRow = null; public ?array $productRow = null;
/**
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
*
* @var array<string, mixed>|null
*/
public ?array $menuRow = null;
/**
* Lignes renvoyees par MenuRepository::all().
*
* @var list<array<string, mixed>>
*/
public array $menusRows = [];
/**
* Lignes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions().
*
* @var list<array<string, mixed>>
*/
public array $menuSlotRows = [];
/** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */
public bool $menuReferenced = false;
/** /**
* Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ;
* null = email inconnu/inactif. * null = email inconnu/inactif.
@ -230,6 +254,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->categoryRow; return $this->categoryRow;
} }
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
return $this->menuRow;
}
if (str_contains($sql, 'FROM order_item WHERE menu_id')) {
return $this->menuReferenced ? ['menu_id' => 1] : null;
}
if (str_contains($sql, 'FROM category WHERE name = :name')) { if (str_contains($sql, 'FROM category WHERE name = :name')) {
return $this->categoryNameTaken ? ['id' => 1] : null; return $this->categoryNameTaken ? ['id' => 1] : null;
} }
@ -269,6 +301,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->productsRows; return $this->productsRows;
} }
if (str_contains($sql, 'FROM menu m JOIN category')) {
return $this->menusRows;
}
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->menuSlotRows;
}
if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
if (!$this->roleActive) { if (!$this->roleActive) {
return []; return [];

View file

@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PDOException;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\PasswordHasher;
use App\Auth\SessionManager;
use App\Controllers\MenuController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
/**
* Sous-classe de test : seam db() (FakeDatabase) + sessionManager() (session test).
*/
final class TestMenuController extends MenuController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function sessionManager(): SessionManager
{
return $this->testSession;
}
protected function db(): DatabaseInterface
{
return $this->fakeDb;
}
}
final class MenuControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private SessionManager $session;
private string $csrf = '';
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
$this->setEnv('ARGON2_MEMORY_COST', '1024');
$this->setEnv('ARGON2_TIME_COST', '1');
$this->setEnv('ARGON2_THREADS', '1');
$this->session = new SessionManager(new Config(), true);
$now = time();
$this->session->set('user_id', 1);
$this->session->set('role_id', 1);
$this->session->set('logged_in_at', $now - 100);
$this->session->set('last_activity', $now - 50);
$this->csrf = Csrf::token($this->session);
}
protected function tearDown(): void
{
foreach ($this->touchedKeys as $key) {
putenv($key);
}
$this->touchedKeys = [];
}
private function setEnv(string $key, string $value): void
{
$this->touchedKeys[] = $key;
putenv($key . '=' . $value);
}
private function permittedDb(): FakeDatabase
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['menu.read', 'menu.create', 'menu.update', 'menu.delete'];
$db->categoryRow = ['id' => 1, 'name' => 'Menus']; // categoryExists -> true
$db->productRow = ['id' => 1, 'name' => 'Big Mac']; // productExists -> true (burger + options)
return $db;
}
private function get(string $path): Request
{
return new Request('GET', $path, [], [], '', '203.0.113.5');
}
/**
* @param array<string, string> $form
*/
private function post(array $form, string $path): Request
{
return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5');
}
private function controller(Request $request, FakeDatabase $db): TestMenuController
{
return new TestMenuController($request, new Config(), new Database(new Config()), $this->session, $db);
}
/**
* @param array<string, string> $overrides
* @return array<string, string>
*/
private function validForm(array $overrides = []): array
{
$slots = (string) json_encode([
['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => [1]],
]);
return array_merge([
'_csrf' => $this->csrf,
'category_id' => '1',
'burger_product_id' => '1',
'name' => 'Best Of',
'price_normal_cents' => '790',
'price_maxi_cents' => '990',
'display_order' => '1',
'is_available' => '1',
'slots_json' => $slots,
], $overrides);
}
private function actingPin(FakeDatabase $db): void
{
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')];
}
public function testIndexRequiresMenuRead(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($this->get('/admin/menus'), $db)->index()->status());
}
public function testIndexListsMenus(): void
{
$db = $this->permittedDb();
$db->menusRows = [
['id' => 1, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of Big Mac', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0, 'category_name' => 'Menus', 'burger_name' => 'Big Mac'],
];
$response = $this->controller($this->get('/admin/menus'), $db)->index();
self::assertSame(200, $response->status());
self::assertStringContainsString('Best Of Big Mac', $response->body());
self::assertStringContainsString('Nouveau menu', $response->body());
}
public function testStoreCreatesMenuWithSlots(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store();
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('INSERT INTO menu'));
self::assertTrue($db->wrote('INSERT INTO menu_slot'));
self::assertTrue($db->wrote('INSERT INTO menu_slot_option'));
self::assertFalse($db->wrote('INSERT INTO audit_log')); // create = pas d'action sensible (mlt 8.4)
self::assertSame('Menu cree.', $this->session->get('_flash'));
}
public function testStoreRejectsWithoutSlots(): void
{
$db = $this->permittedDb();
// Precondition mlt 8.4 : >=1 slot avec >=1 option. Ici aucun slot.
$response = $this->controller($this->post($this->validForm(['slots_json' => '[]']), '/admin/menus'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO menu'));
}
public function testStoreRejectsSlotWithoutOption(): void
{
$db = $this->permittedDb();
$slots = (string) json_encode([['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => []]]);
$response = $this->controller($this->post($this->validForm(['slots_json' => $slots]), '/admin/menus'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO menu'));
}
public function testStoreRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(['_csrf' => 'wrong']), '/admin/menus'), $db)->store();
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('INSERT INTO menu'));
}
public function testUpdateRebuildsSlots(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0];
$response = $this->controller($this->post($this->validForm(), '/admin/menus/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('UPDATE menu SET'));
// delete-and-reinsert des slots (mlt 8.5 RG-2).
self::assertTrue($db->wrote('DELETE FROM menu_slot'));
self::assertTrue($db->wrote('INSERT INTO menu_slot'));
}
public function testDestroyLockedActorReturns422WithoutDeletingOrAuditing(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Best Of'];
$this->actingPin($db);
$db->pinThrottleLockoutUntil = '2099-01-01 00:00:00';
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('DELETE FROM menu'));
self::assertSame([], $db->auditActions());
}
public function testDestroyWrongPinRecordsFailureOnSessionActor(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Best Of'];
$db->actingUserRow = null; // email/PIN invalide
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(422, $response->status());
self::assertSame(['pin.failed'], $db->auditActions());
self::assertTrue($db->wrote('INSERT INTO pin_throttle')); // RG-T22 increment sur l'agissant
// RG-T08 : pin.failed + increment throttle dans UNE transaction.
self::assertSame(['begin', 'commit'], $db->transactionEvents);
}
public function testDestroyValidPinDeletesAuditsAndResets(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Best Of'];
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('DELETE FROM menu'));
self::assertSame(['menu.delete'], $db->auditActions());
// L'audit porte l'acteur RESOLU PAR PIN (id 9), dans la transaction de l'effet.
$audit = $this->findWrite($db, 'INSERT INTO audit_log');
self::assertNotNull($audit);
self::assertSame(9, $audit['params']['uid'] ?? null);
$this->assertAuditWithinTransaction($db);
// Reset du throttle sur l'acteur de SESSION (id 1).
$reset = $this->findWrite($db, 'UPDATE pin_throttle SET failed_attempts = 0');
self::assertNotNull($reset);
self::assertSame(1, $reset['params']['uid'] ?? null);
}
public function testDestroyReferencedByOrderReturns422(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Best Of'];
$this->actingPin($db);
$db->failOnExecute = new PDOException('referenced', 23000); // FK order_item.menu_id RESTRICT
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(422, $response->status());
self::assertStringContainsString('suppression impossible', $response->body());
}
public function testToggleFlipsAvailability(): void
{
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Best Of', 'is_available' => 1];
$response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/menus/5/toggle'), $db)->toggle(['id' => '5']);
self::assertSame(302, $response->status());
$write = $this->findWrite($db, 'UPDATE menu SET is_available');
self::assertNotNull($write);
self::assertSame(0, $write['params']['a'] ?? null); // 1 -> 0
}
/**
* @return array{sql: string, params: array<string|int, mixed>}|null
*/
private function findWrite(FakeDatabase $db, string $needle): ?array
{
foreach ($db->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write;
}
}
return null;
}
private function assertAuditWithinTransaction(FakeDatabase $db): void
{
$log = $db->eventLog;
$begin = array_search('begin', $log, true);
$commit = array_search('commit', $log, true);
$auditAt = null;
foreach ($log as $i => $event) {
if (str_contains($event, 'INSERT INTO audit_log')) {
$auditAt = $i;
}
}
self::assertIsInt($begin);
self::assertIsInt($commit);
self::assertNotNull($auditAt);
self::assertTrue($begin < $auditAt && $auditAt < $commit, 'audit_log doit etre ecrit entre begin et commit');
}
}