diff --git a/db/migrations/0005_ingredient_nutrition.sql b/db/migrations/0005_ingredient_nutrition.sql new file mode 100644 index 0000000..838233a --- /dev/null +++ b/db/migrations/0005_ingredient_nutrition.sql @@ -0,0 +1,25 @@ +-- db/migrations/0005_ingredient_nutrition.sql +-- ============================================================================= +-- Wakdo - Migration 0005 : enrichissement nutritionnel depuis une API EXTERNE +-- ============================================================================= +-- Purpose : ajoute a `ingredient` des colonnes nullables pour stocker des donnees +-- nutritionnelles importees depuis une API TIERCE (OpenFoodFacts), a la +-- demande d'un manager/admin (action explicite, pas au runtime borne). +-- Demontre l'exploitation, DANS LE MODELE de donnees, d'informations +-- externes provenant d'une API (Cr 3.a.3). Egress maitrise et opt-in : +-- aucun appel automatique ; la passerelle (App\Catalogue\ +-- OpenFoodFactsGateway) est invoquee seulement par IngredientController::enrich. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE ingredient + ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label, + ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g, + ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source; + +-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les +-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts"). +-- nutrition_fetched_at : horodatage de l'import, pour tracer la fraicheur. Toutes +-- nullables : un ingredient non enrichi reste valide (donnee optionnelle). diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 73f16db..af05428 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -310,7 +310,7 @@ Reseaux : | Critere | Libelle court | Feature Wakdo couvrant | |---|---|---| | Cr 3.a.1-4 | Analyse + modele donnees | Dictionnaire + MCD + cardinalites | -| Cr 3.a.3 | Exploiter donnees externes d'API | API interne consommee par le front (auto-consommation) | +| Cr 3.a.3 | Exploiter donnees externes d'API | Enrichissement nutritionnel depuis **OpenFoodFacts** (API tierce) importe DANS le modele (`ingredient.energy_kcal_100g`), a la demande admin (opt-in, sans egress runtime) ; + auto-consommation de l'API interne par la borne | | Cr 3.b.1-3 | Construction BDD | MCD → MLD → DDL MariaDB, FK + typage coherent | | Cr 3.c.1-3 | Requetes SQL optimisees | PDO prepared, index sur FK, LIMIT/tri explicites | | Cr 3.d.1-4 | RGPD | hash mdp, droit acces/modif/suppr, info utilisation donnees | diff --git a/src/app/Catalogue/IngredientRepository.php b/src/app/Catalogue/IngredientRepository.php index 7d9e6b5..ad7a814 100644 --- a/src/app/Catalogue/IngredientRepository.php +++ b/src/app/Catalogue/IngredientRepository.php @@ -44,6 +44,7 @@ final class IngredientRepository { $rows = $this->db->fetchAll( 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, ' . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name', ); @@ -57,6 +58,7 @@ final class IngredientRepository { $row = $this->db->fetch( 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, ' . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id', ['id' => $id], ); @@ -148,6 +150,22 @@ final class IngredientRepository * (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux * FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure. */ + /** + * Enregistre les donnees nutritionnelles importees d'une source externe + * (Cr 3.a.3). Allowlist de colonnes (RG-T16) : seules energy / source / + * fetched_at sont ecrites ; l'horodatage est pose cote SQL (NOW()). + * + * @param array{energy_kcal_100g:int, source:string} $data + */ + public function setNutrition(int $id, array $data): int + { + return $this->db->execute( + 'UPDATE ingredient SET energy_kcal_100g = :kcal, nutrition_source = :src, ' + . 'nutrition_fetched_at = NOW() WHERE id = :id', + ['kcal' => $data['energy_kcal_100g'], 'src' => $data['source'], 'id' => $id], + ); + } + public function isReferenced(int $id): bool { if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) { diff --git a/src/app/Catalogue/NutritionGateway.php b/src/app/Catalogue/NutritionGateway.php new file mode 100644 index 0000000..22e1491 --- /dev/null +++ b/src/app/Catalogue/NutritionGateway.php @@ -0,0 +1,23 @@ + l'appelant affiche + * un message sans interrompre l'usage. + */ +final class OpenFoodFactsGateway implements NutritionGateway +{ + private const ENDPOINT = 'https://world.openfoodfacts.org/cgi/search.pl'; + + public function __construct(private readonly int $timeoutSeconds = 5) + { + } + + public function lookupByName(string $name): ?array + { + $name = trim($name); + if ($name === '' || !function_exists('curl_init')) { + return null; + } + + $url = self::ENDPOINT . '?' . http_build_query([ + 'search_terms' => $name, + 'search_simple' => 1, + 'action' => 'process', + 'json' => 1, + 'page_size' => 1, + 'fields' => 'product_name,nutriments', + ]); + + $handle = curl_init($url); + if ($handle === false) { + return null; + } + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_USERAGENT => 'Wakdo/1.0 (projet pedagogique RNCP)', + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + curl_close($handle); + + if (!is_string($body) || $status < 200 || $status >= 300) { + return null; + } + + return $this->parse($body); + } + + /** + * Extrait l'apport energetique du premier produit de la reponse OpenFoodFacts. + * Isole pour etre testable sans reseau. + * + * @return array{energy_kcal_100g:int, source:string}|null + */ + public function parse(string $body): ?array + { + $data = json_decode($body, true); + if (!is_array($data) || !isset($data['products'][0]['nutriments']) + || !is_array($data['products'][0]['nutriments'])) { + return null; + } + + $kcal = $data['products'][0]['nutriments']['energy-kcal_100g'] ?? null; + if (!is_numeric($kcal)) { + return null; + } + + $kcal = (int) round((float) $kcal); + if ($kcal < 0 || $kcal > 65535) { + return null; + } + + return ['energy_kcal_100g' => $kcal, 'source' => 'OpenFoodFacts']; + } +} diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php index f8cca52..08f5649 100644 --- a/src/app/Controllers/IngredientController.php +++ b/src/app/Controllers/IngredientController.php @@ -11,6 +11,8 @@ use App\Auth\PasswordHasher; use App\Auth\PinThrottle; use App\Auth\PinVerifier; use App\Catalogue\IngredientRepository; +use App\Catalogue\NutritionGateway; +use App\Catalogue\OpenFoodFactsGateway; use App\Core\DatabaseInterface; use App\Core\Response; @@ -185,6 +187,52 @@ class IngredientController extends AdminController return $this->redirect('/admin/ingredients'); } + /** + * Enrichit un ingredient avec des donnees nutritionnelles importees d'une API + * EXTERNE (OpenFoodFacts, Cr 3.a.3). Action explicite (POST + CSRF), gardee par + * ingredient.manage, SANS PIN (hors ensemble sensible RG-T13). Tolerante : si la + * source ne renvoie rien, on le signale sans erreur (le flux reste utilisable). + * + * @param array $params + */ + public function enrich(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + 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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $data = $this->nutritionGateway()->lookupByName((string) ($ingredient['name'] ?? '')); + if ($data === null) { + $this->setFlash('Aucune donnee nutritionnelle trouvee pour cet ingredient (source externe).'); + } else { + $this->ingredientRepository()->setNutrition($id, $data); + $this->setFlash('Donnees nutritionnelles importees depuis ' . $data['source'] . '.'); + } + + return $this->redirect('/admin/ingredients/' . $id . '/edit'); + } + + /** + * Passerelle nutritionnelle externe. Hook protege : les tests redefinissent ce + * seam pour injecter un double sans appel reseau. + */ + protected function nutritionGateway(): NutritionGateway + { + return new OpenFoodFactsGateway(); + } + /** * @param array $params */ diff --git a/src/app/Views/admin/ingredients/form.php b/src/app/Views/admin/ingredients/form.php index a8cbeba..6b677cf 100644 --- a/src/app/Views/admin/ingredients/form.php +++ b/src/app/Views/admin/ingredients/form.php @@ -86,3 +86,21 @@ $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) Annuler + + + +
+

Valeur nutritionnelle

+ +

Apport energetique : kcal / 100 g + (source : , importe le )

+ +

Aucune donnee nutritionnelle importee pour le moment.

+ + +
+ + +
+
+ diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 4ae17bb..ee2365d 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -197,6 +197,9 @@ try { $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); + // Enrichissement nutritionnel depuis une API externe (OpenFoodFacts, Cr 3.a.3) : + // action explicite ingredient.manage, POST + CSRF, opt-in (pas d'egress automatique). + $router->add('POST', '/admin/ingredients/{id}/enrich', [IngredientController::class, 'enrich']); // CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le // routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse. diff --git a/tests/Support/FakeNutritionGateway.php b/tests/Support/FakeNutritionGateway.php new file mode 100644 index 0000000..cfefa3d --- /dev/null +++ b/tests/Support/FakeNutritionGateway.php @@ -0,0 +1,26 @@ +lookedUp = $name; + + return $this->result; + } +} diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php index cb7a23c..d687de4 100644 --- a/tests/Unit/Admin/IngredientControllerTest.php +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -9,12 +9,14 @@ use PHPUnit\Framework\TestCase; use App\Auth\Csrf; use App\Auth\PasswordHasher; use App\Auth\SessionManager; +use App\Catalogue\NutritionGateway; use App\Controllers\IngredientController; use App\Core\Config; use App\Core\Database; use App\Core\DatabaseInterface; use App\Core\Request; use App\Tests\Support\FakeDatabase; +use App\Tests\Support\FakeNutritionGateway; /** * Sous-classe de test : le seam db() injecte le double, sessionManager() la session. @@ -40,6 +42,13 @@ final class TestIngredientController extends IngredientController { return $this->fakeDb; } + + public ?FakeNutritionGateway $fakeGateway = null; + + protected function nutritionGateway(): NutritionGateway + { + return $this->fakeGateway ?? new FakeNutritionGateway(); + } } final class IngredientControllerTest extends TestCase @@ -289,6 +298,39 @@ final class IngredientControllerTest extends TestCase self::assertSame(0, $params['a']); } + public function testEnrichStoresNutritionFromExternalApi(): void + { + $db = $this->permittedDb(); + $c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db); + $c->fakeGateway = new FakeNutritionGateway(); + $c->fakeGateway->result = ['energy_kcal_100g' => 402, 'source' => 'OpenFoodFacts']; + + $response = $c->enrich(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/ingredients/5/edit', $response->header('Location')); + // La source externe est interrogee avec le NOM de l'ingredient, et la donnee + // retournee est ecrite DANS LE MODELE (Cr 3.a.3). + self::assertSame('Cheddar', $c->fakeGateway->lookedUp); + $params = $this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g'); + self::assertNotNull($params); + self::assertSame(402, $params['kcal']); + self::assertSame('OpenFoodFacts', $params['src']); + } + + public function testEnrichWithoutResultDoesNotWrite(): void + { + $db = $this->permittedDb(); + $c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db); + $c->fakeGateway = new FakeNutritionGateway(); // result reste null + + $response = $c->enrich(['id' => '5']); + + self::assertSame(302, $response->status()); + // Aucune ecriture nutritionnelle si la source externe ne renvoie rien. + self::assertNull($this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g')); + } + public function testDestroyUnreferencedDeletesWithoutPin(): void { $db = $this->permittedDb();