corentin_wakdo/src/app/Controllers/MenuController.php
Corentin JOGUET 0666a22562
All checks were successful
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 53s
CI / auto-merge (push) Has been skipped
fix(admin): conflits HTTP 409 au lieu de 422 (delete FK-bloque + course unicite) (#33)
2026-06-17 10:11:28 +02:00

535 lines
19 KiB
PHP

<?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 -> 409 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 -> 409 Conflit (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.', 409);
}
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, ?int $status = null): Response
{
return $this->adminView('admin/menus/delete', [
'title' => 'Supprimer un menu - Wakdo Admin',
'activeNav' => 'menus',
'menuId' => $id,
'name' => (string) ($menu['name'] ?? ''),
'error' => $error,
], $guard, $status ?? ($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],
);
}
}