Compare commits

..

5 commits

Author SHA1 Message Date
Imugiii
e265b0cb92 Merge remote-tracking branch 'forgejo/dev' into feat/p2-core-php
Some checks failed
CI / static-tests (pull_request) Failing after 35s
CI / secret-scan (pull_request) Successful in 11s
CI / php-lint (pull_request) Successful in 26s
CI / secret-scan (push) Successful in 13s
CI / php-lint (push) Successful in 26s
CI / static-tests (push) Failing after 31s
CI / auto-merge (pull_request) Has been skipped
CI / auto-merge (push) Has been skipped
2026-06-15 14:13:49 +00:00
Imugiii
8c93b26ec0 feat(core): from-scratch PHP MVC skeleton (autoloader/config/PDO/router/front controller) + PHPUnit/PHPStan + composer-less CI 2026-06-15 14:13:49 +00:00
dca5860869 Merge pull request 'feat(db): seed data (RBAC + allergens + catalogue)' (#8) from feat/p2-seed-data into dev
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 18s
CI / static-tests (push) Successful in 5s
CI / auto-merge (push) Has been skipped
Reviewed-on: #8
2026-06-15 15:57:14 +02:00
Imugiii
04404dc8c5 docs: clarify manager has read-only user access (user.read), not zero access
All checks were successful
CI / secret-scan (pull_request) Successful in 7s
CI / php-lint (pull_request) Successful in 18s
CI / static-tests (pull_request) Successful in 4s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Has been skipped
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 18s
CI / static-tests (push) Successful in 5s
2026-06-15 13:47:58 +00:00
Imugiii
fcf52a0895 feat(db): seed data - RBAC matrix + INCO allergens + admin user + catalogue (9 cat / 53 products / 13 menus + composition)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / static-tests (push) Successful in 9s
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (push) Successful in 26s
CI / php-lint (pull_request) Successful in 25s
CI / static-tests (pull_request) Successful in 7s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Has been skipped
2026-06-15 13:45:14 +00:00
23 changed files with 1538 additions and 46 deletions

View file

@ -58,30 +58,42 @@ jobs:
static-tests:
runs-on: docker
# COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit
# tournent depuis leur .phar autonome telecharge ici, jamais via Composer.
# Versions epinglees pour des CI reproductibles (pas de "latest").
env:
PHPUNIT_VERSION: "11.5.2"
PHPSTAN_VERSION: "1.12.27"
steps:
- uses: actions/checkout@v4
- name: PHPStan (guarded)
run: |
if [ -f composer.json ] && [ -f phpstan.neon ]; then
echo "phpstan config detected - running"
apt-get update -qq && apt-get install -y -qq php-cli unzip git >/dev/null
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
composer install --no-interaction --no-progress
vendor/bin/phpstan analyse --no-progress
else
echo "PHPStan skipped: no composer.json/phpstan.neon yet (activates in P2)"
set -eu
if [ ! -f phpstan.neon ]; then
echo "PHPStan skipped: no phpstan.neon yet (activates in P2)"
exit 0
fi
echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar"
apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null
# PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar)
# resolve les symboles PHPUnit\Framework\* utilises sous tests/.
curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar
curl -sSL "https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar" -o phpstan.phar
php phpstan.phar --version
# memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli.
php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw
- name: PHPUnit (guarded)
run: |
if [ -d tests ] && [ -f phpunit.xml ]; then
echo "phpunit config detected - running"
apt-get update -qq && apt-get install -y -qq php-cli >/dev/null
if [ -f vendor/bin/phpunit ]; then vendor/bin/phpunit; \
elif [ -f phpunit.phar ]; then php phpunit.phar; \
else echo "phpunit binary missing despite config" && exit 1; fi
else
set -eu
if [ ! -d tests ] || [ ! -f phpunit.xml ]; then
echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)"
exit 0
fi
echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar"
apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null
curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar
php phpunit.phar --version
php phpunit.phar -c phpunit.xml
auto-merge:
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.

5
.gitignore vendored
View file

@ -28,8 +28,11 @@ vendor/
composer.lock
composer.phar
# === Tests ===
# === Tests / Analyse statique (tooling via .phar autonome, sans Composer) ===
.phpunit.result.cache
.phpunit.cache/
/phpunit.phar
/phpstan.phar
/tests/_output/
/tests/_support/_generated/

View file

@ -0,0 +1,190 @@
-- =============================================================================
-- Wakdo — Seed 0001 : RBAC + reference data + admin user
-- =============================================================================
-- Purpose : Seed the foundational rows the back-office cannot boot without:
-- the 5 RBAC roles, the frozen catalogue of 23 permissions, the
-- default role/permission matrix, per-role visible order sources,
-- the 14 EU INCO allergens, and a single bootstrap admin user.
-- Source : docs/merise/dictionary.md (3.8 allergen, 3.15 role, 3.16
-- role_visible_source, 3.17 permission catalogue + default grants,
-- 3.18 role_permission), docs/merise/mct.md (operations 1-28),
-- docs/PROJECT_CONTEXT.md section 7 (role responsibilities) and
-- decision D5 (admin gets order.create / order.deliver ; manager
-- does NOT get order.cancel).
-- Phase : P2 — demo/reference seed, applied AFTER db/migrations/0001_init_schema.sql.
-- Target : MariaDB 11.4 LTS. Fed by db/seed.sh into the already-selected DB.
--
-- Notes:
-- - Statements are ordered so every FK resolves: role and permission first,
-- then role_permission / role_visible_source, then user (FK -> role).
-- - role_permission rows use subqueries on role.code and permission.code so
-- no surrogate ids are hardcoded (robust to AUTO_INCREMENT gaps).
-- - admin/manager get no role_visible_source rows: they have a global view of
-- all sources (the absence of rows means "no source filter applied").
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 1. role (5) — dictionary.md 3.15
-- order_source: counter/drive auto-tag their own source; admin/manager NULL
-- (they may create on behalf of any channel); kitchen NULL (read-only on
-- orders, never creates one).
-- -----------------------------------------------------------------------------
INSERT INTO role (code, label, description, default_route, order_source, is_active) VALUES
('admin', 'Administrator', 'Full back-office access: complete catalogue CRUD (incl. deletes), user/role/permission (RBAC) management, stock, stats, order create/deliver/cancel.', '/admin/dashboard', NULL, 1),
('manager', 'Manager', 'Catalogue create/update, ingredient and stock management (restock + inventory), statistics. No user/RBAC administration, no order cancellation.', '/admin/stats', NULL, 1),
('kitchen', 'Kitchen Staff', 'Read-only kitchen display (KDS) of paid orders sorted by paid_at ascending, plus inventory counting. Performs no order status transition.', '/kitchen/display', NULL, 1),
('counter', 'Counter Staff', 'Takes orders at the counter, delivers them to the customer, can cancel. Inventory counting. source auto-tagged as counter.', '/counter/orders', 'counter', 1),
('drive', 'Drive Staff', 'Takes orders at the drive-thru (intercom + headset), delivers them, can cancel. Inventory counting. source auto-tagged as drive.', '/drive/orders', 'drive', 1);
-- -----------------------------------------------------------------------------
-- 2. permission (23) — frozen catalogue, dictionary.md 3.17.
-- code format <resource>.<action>. The catalogue is fixed at the seed and
-- never created through the UI (only assigned to roles via MANAGE_RBAC).
-- -----------------------------------------------------------------------------
INSERT INTO permission (code, label, description) VALUES
('product.create', 'Create product', 'Create a new catalogue product.'),
('product.read', 'Read products', 'View products in the back-office and on order screens.'),
('product.update', 'Update product', 'Edit an existing product (name, price, VAT, availability, etc.).'),
('product.delete', 'Delete product', 'Permanently delete a product when no FK references block it.'),
('menu.create', 'Create menu', 'Create a new menu with its slot configuration.'),
('menu.read', 'Read menus', 'View menus, slots and slot options.'),
('menu.update', 'Update menu', 'Edit an existing menu and its slot configuration.'),
('menu.delete', 'Delete menu', 'Permanently delete a menu when no historical order references it.'),
('category.manage', 'Manage categories', 'Create, update or deactivate product/menu categories.'),
('ingredient.manage', 'Manage ingredients', 'Manage ingredients, product composition and allergen mapping.'),
('stock.read', 'Read stock', 'View ingredient stock levels and movement history.'),
('stock.count', 'Count stock', 'Record a physical inventory count (inventory correction).'),
('stock.manage', 'Manage stock', 'Record restocks (pack deliveries) and manage stock parameters.'),
('order.read', 'Read orders', 'View orders and the preparation display.'),
('order.create', 'Create order', 'Create an order at the counter or drive-thru.'),
('order.deliver', 'Deliver order', 'Mark a paid order as delivered (single-gesture handover).'),
('order.cancel', 'Cancel order', 'Cancel a pending or paid order (restocks ingredients if paid).'),
('user.create', 'Create user', 'Create a new back-office user.'),
('user.read', 'Read users', 'View the list and details of back-office users.'),
('user.update', 'Update user', 'Edit a back-office user (incl. password reset, RGPD anonymisation).'),
('user.deactivate', 'Deactivate user', 'Deactivate a back-office user without deleting the row.'),
('role.manage', 'Manage roles and RBAC', 'Manage roles, role/permission assignments and visible sources.'),
('stats.read', 'Read statistics', 'Access the statistics / KPI dashboard.');
-- -----------------------------------------------------------------------------
-- 3. role_permission — default matrix, dictionary.md 3.17 grants + PROJECT_CONTEXT
-- section 7 + decision D5. Subqueries on role.code / permission.code avoid
-- hardcoded ids.
-- -----------------------------------------------------------------------------
-- admin: ALL 23 permissions (cross join the admin role with the whole catalogue).
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM role r
CROSS JOIN permission p
WHERE r.code = 'admin';
-- manager: catalogue create/update + category/ingredient + full stock + stats.
-- NO order.* (incl. no order.cancel per D5), NO user/role admin.
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM role r
JOIN permission p ON p.code IN (
'product.create', 'product.read', 'product.update',
'menu.create', 'menu.read', 'menu.update',
'category.manage', 'ingredient.manage',
'stock.read', 'stock.count', 'stock.manage',
'user.read',
'stats.read'
)
WHERE r.code = 'manager';
-- kitchen: read-only orders + read-only catalogue + inventory (read + count).
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM role r
JOIN permission p ON p.code IN (
'product.read', 'menu.read',
'stock.read', 'stock.count',
'order.read'
)
WHERE r.code = 'kitchen';
-- counter: read catalogue + full order lifecycle (read/create/deliver/cancel)
-- + inventory (read + count).
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM role r
JOIN permission p ON p.code IN (
'product.read', 'menu.read',
'stock.read', 'stock.count',
'order.read', 'order.create', 'order.deliver', 'order.cancel'
)
WHERE r.code = 'counter';
-- drive: identical grant set to counter (read catalogue + full order lifecycle
-- + inventory). The source differs (auto-tagged drive), not the rights.
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM role r
JOIN permission p ON p.code IN (
'product.read', 'menu.read',
'stock.read', 'stock.count',
'order.read', 'order.create', 'order.deliver', 'order.cancel'
)
WHERE r.code = 'drive';
-- -----------------------------------------------------------------------------
-- 4. role_visible_source — dictionary.md 3.16.
-- kitchen sees all 3 sources; counter sees kiosk+counter; drive sees drive.
-- admin/manager: no rows -> global view (no source filter).
-- -----------------------------------------------------------------------------
INSERT INTO role_visible_source (role_id, source)
SELECT r.id, s.source
FROM role r
JOIN (
SELECT 'kitchen' AS role_code, 'kiosk' AS source UNION ALL
SELECT 'kitchen', 'counter' UNION ALL
SELECT 'kitchen', 'drive' UNION ALL
SELECT 'counter', 'kiosk' UNION ALL
SELECT 'counter', 'counter' UNION ALL
SELECT 'drive', 'drive'
) s ON s.role_code = r.code;
-- -----------------------------------------------------------------------------
-- 5. allergen (14) — EU INCO Regulation (EU) No 1169/2011, Annex II.
-- dictionary.md 3.8. code = machine code (en), name = French display label.
-- -----------------------------------------------------------------------------
INSERT INTO allergen (code, name, description) VALUES
('gluten', 'Gluten', 'Cereales contenant du gluten (ble, seigle, orge, avoine, epeautre, kamut) et produits a base de ces cereales.'),
('crustaceans', 'Crustaces', 'Crustaces et produits a base de crustaces.'),
('eggs', 'Oeufs', 'Oeufs et produits a base d''oeufs.'),
('fish', 'Poisson', 'Poissons et produits a base de poissons.'),
('peanuts', 'Arachides', 'Arachides et produits a base d''arachides.'),
('soybeans', 'Soja', 'Soja et produits a base de soja.'),
('milk', 'Lait', 'Lait et produits a base de lait (y compris le lactose).'),
('nuts', 'Fruits a coque', 'Fruits a coque : amandes, noisettes, noix, noix de cajou, de pecan, du Bresil, pistaches, noix de Macadamia.'),
('celery', 'Celeri', 'Celeri et produits a base de celeri.'),
('mustard', 'Moutarde', 'Moutarde et produits a base de moutarde.'),
('sesame', 'Graines de sesame', 'Graines de sesame et produits a base de graines de sesame.'),
('sulphites', 'Anhydride sulfureux et sulfites', 'Anhydride sulfureux et sulfites en concentration superieure a 10 mg/kg ou 10 mg/l (exprimes en SO2).'),
('lupin', 'Lupin', 'Lupin et produits a base de lupin.'),
('molluscs', 'Mollusques', 'Mollusques et produits a base de mollusques.');
-- -----------------------------------------------------------------------------
-- 6. user (1) — bootstrap administrator. dictionary.md 3.14.
-- role_id resolved from role.code = 'admin'. pin_hash NULL (no PIN set yet).
--
-- DEV password: WakdoAdmin2026! (argon2id hash below, generated via
-- `docker exec wakdo-app php -r 'echo password_hash("WakdoAdmin2026!",
-- PASSWORD_ARGON2ID);'`). MUST be changed in production — this is a known
-- demo credential and must never reach a real deployment as-is.
-- -----------------------------------------------------------------------------
INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active)
SELECT
'admin@wakdo.local',
'$argon2id$v=19$m=65536,t=4,p=1$V3dVMi55cDVBYVZPMU1TRw$8iMoNyfC12t7V2CU+YgqwvEb3xNywm7PUSIoNMgRdvc',
NULL,
'Wakdo',
'Admin',
r.id,
1
FROM role r
WHERE r.code = 'admin';

195
db/seeds/0002_catalogue.sql Normal file
View file

@ -0,0 +1,195 @@
-- =============================================================================
-- Wakdo — Seed 0002 : Catalogue (reference / demo data)
-- =============================================================================
-- Purpose : Populate the Catalogue sub-domain (category, product, menu,
-- menu_slot, menu_slot_option) from the school JSON sources.
-- Sources : docs/merise/_sources/categories.json (9 categories)
-- docs/merise/_sources/produits.json (menus + 53 products)
-- src/public/borne/data/produits.json (cents prices + clean paths,
-- used for cross-check)
-- Phase : P2 — demo seed, assumes a fresh schema (0001_init_schema.sql).
--
-- Conventions:
-- - Monetary amounts are INT in CENTS (euros float x 100, rounded).
-- - vat_rate is per-mille: 100 = 10% (default), 55 = 5.5% for products in
-- resealable containers (bottled water, bottled juices) — dictionary note 9.
-- - image_path is a relative path under the public root, normalised to
-- assets/images/produits/<category>/<file>.png (dictionary note 8).
-- - Menus go to the `menu` table (NOT `product`); every other category goes
-- to `product`. The "burgers" category items are the anchor products that
-- menus reference via burger_product_id.
-- - price_maxi_cents = price_normal_cents + 150 (Maxi format, +1.50 EUR).
-- - Foreign keys are resolved by subquery on natural keys (slug / name)
-- rather than hardcoded ids.
-- - Insertion order respects FK dependencies:
-- category -> product -> menu -> menu_slot -> menu_slot_option.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 1. category (9) — root table, source order = display_order
-- -----------------------------------------------------------------------------
INSERT INTO category (name, slug, image_path, display_order, is_active) VALUES
('menus', 'menus', 'assets/images/categories/menus.png', 1, 1),
('boissons', 'boissons', 'assets/images/categories/boissons.png', 2, 1),
('burgers', 'burgers', 'assets/images/categories/burgers.png', 3, 1),
('frites', 'frites', 'assets/images/categories/frites.png', 4, 1),
('encas', 'encas', 'assets/images/categories/encas.png', 5, 1),
('wraps', 'wraps', 'assets/images/categories/wraps.png', 6, 1),
('salades', 'salades', 'assets/images/categories/salades.png', 7, 1),
('desserts', 'desserts', 'assets/images/categories/desserts.png', 8, 1),
('sauces', 'sauces', 'assets/images/categories/sauces.png', 9, 1);
-- -----------------------------------------------------------------------------
-- 2. product — every non-menu item (53 rows)
-- category_id resolved via subquery on category.slug.
-- display_order follows source order within each category.
-- vat_rate defaults to 100; 55 only for resealable-container drinks
-- (Eau, Jus d'Orange, Jus de Pommes Bio) per dictionary note 9.
-- -----------------------------------------------------------------------------
-- 2.a burgers (anchor products for menus)
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='burgers'), 'Le 280', 680, 100, 'assets/images/produits/burgers/280.png', 1, 1),
((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty', 860, 100, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2),
((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty Bacon', 890, 100, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3),
((SELECT id FROM category WHERE slug='burgers'), 'Big Mac', 600, 100, 'assets/images/produits/burgers/bigmac.png', 1, 4),
((SELECT id FROM category WHERE slug='burgers'), 'CBO', 890, 100, 'assets/images/produits/burgers/cbo.png', 1, 5),
((SELECT id FROM category WHERE slug='burgers'), 'MC Chicken', 730, 100, 'assets/images/produits/burgers/mcchicken.png', 1, 6),
((SELECT id FROM category WHERE slug='burgers'), 'MC Crispy', 530, 100, 'assets/images/produits/burgers/mccrispy.png', 1, 7),
((SELECT id FROM category WHERE slug='burgers'), 'MC Fish', 485, 100, 'assets/images/produits/burgers/mcfish.png', 1, 8),
((SELECT id FROM category WHERE slug='burgers'), 'Royal Bacon', 510, 100, 'assets/images/produits/burgers/royalbacon.png', 1, 9),
((SELECT id FROM category WHERE slug='burgers'), 'Royal Cheese', 440, 100, 'assets/images/produits/burgers/royalcheese.png', 1, 10),
((SELECT id FROM category WHERE slug='burgers'), 'Royal Deluxe', 540, 100, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11),
((SELECT id FROM category WHERE slug='burgers'), 'Signature BBQ Beef 2 viandes', 1140, 100, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12),
((SELECT id FROM category WHERE slug='burgers'), 'Signature Beef BBQ', 1030, 100, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13);
-- 2.b boissons (Eau + the two bottled juices are resealable-container = vat_rate 55)
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='boissons'), 'Coca Cola', 190, 100, 'assets/images/produits/boissons/coca-cola.png', 1, 1),
((SELECT id FROM category WHERE slug='boissons'), 'Coca Sans Sucres', 190, 100, 'assets/images/produits/boissons/coca-sans-sucres.png', 1, 2),
((SELECT id FROM category WHERE slug='boissons'), 'Eau', 100, 55, 'assets/images/produits/boissons/eau.png', 1, 3),
((SELECT id FROM category WHERE slug='boissons'), 'Fanta Orange', 190, 100, 'assets/images/produits/boissons/fanta.png', 1, 4),
((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Peche', 190, 100, 'assets/images/produits/boissons/ice-tea-peche.png', 1, 5),
((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Citron', 190, 100, 'assets/images/produits/boissons/the-vert-citron-sans-sucres.png', 1, 6),
((SELECT id FROM category WHERE slug='boissons'), 'Jus d''Orange', 210, 55, 'assets/images/produits/boissons/jus-orange.png', 1, 7),
((SELECT id FROM category WHERE slug='boissons'), 'Jus de Pommes Bio', 230, 55, 'assets/images/produits/boissons/jus-pomme-bio.png', 1, 8);
-- 2.c frites
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='frites'), 'Petite Frite', 145, 100, 'assets/images/produits/frites/petite-frite.png', 1, 1),
((SELECT id FROM category WHERE slug='frites'), 'Moyenne Frite', 275, 100, 'assets/images/produits/frites/moyenne-frite.png', 1, 2),
((SELECT id FROM category WHERE slug='frites'), 'Grande Frite', 350, 100, 'assets/images/produits/frites/grande-frite.png', 1, 3),
((SELECT id FROM category WHERE slug='frites'), 'Potatoes', 215, 100, 'assets/images/produits/frites/potatoes.png', 1, 4),
((SELECT id FROM category WHERE slug='frites'), 'Grande Potatoes', 340, 100, 'assets/images/produits/frites/grande-potatoes.png', 1, 5);
-- 2.d encas
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='encas'), 'Cheeseburger', 260, 100, 'assets/images/produits/encas/cheeseburger.png', 1, 1),
((SELECT id FROM category WHERE slug='encas'), 'Croc MCdo', 320, 100, 'assets/images/produits/encas/croc-mc-do.png', 1, 2),
((SELECT id FROM category WHERE slug='encas'), 'Nuggets x4', 420, 100, 'assets/images/produits/encas/nuggets-4.png', 1, 3),
((SELECT id FROM category WHERE slug='encas'), 'Nuggets x20', 1300, 100, 'assets/images/produits/encas/nuggets-20.png', 1, 4);
-- 2.e wraps
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Chevre', 310, 100, 'assets/images/produits/wraps/mcwrap-chevre.png', 1, 1),
((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Poulet Bacon', 330, 100, 'assets/images/produits/wraps/mcwrap-poulet-bacon.png', 1, 2),
((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Chevre', 260, 100, 'assets/images/produits/wraps/ptit-wrap-chevre.png', 1, 3),
((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Ranch', 260, 100, 'assets/images/produits/wraps/ptit-wrap-ranch.png', 1, 4);
-- 2.f salades
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='salades'), 'Petite Salade', 330, 100, 'assets/images/produits/salades/petite-salade.png', 1, 1),
((SELECT id FROM category WHERE slug='salades'), 'Cesar Classic', 880, 100, 'assets/images/produits/salades/salade-classic-caesar.png', 1, 2),
((SELECT id FROM category WHERE slug='salades'), 'Italienne Mozza', 880, 100, 'assets/images/produits/salades/salade-italian-mozza.png', 1, 3);
-- 2.g desserts
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='desserts'), 'Brownie', 260, 100, 'assets/images/produits/desserts/brownies.png', 1, 1),
((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake chocolat M&M''S', 310, 100, 'assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png', 1, 2),
((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake Fraise', 310, 100, 'assets/images/produits/desserts/cheesecake-fraise.png', 1, 3),
((SELECT id FROM category WHERE slug='desserts'), 'Cookie', 320, 100, 'assets/images/produits/desserts/cookie.png', 1, 4),
((SELECT id FROM category WHERE slug='desserts'), 'Donut', 260, 100, 'assets/images/produits/desserts/doghnut.png', 1, 5),
((SELECT id FROM category WHERE slug='desserts'), 'Macarons', 270, 100, 'assets/images/produits/desserts/macarons.png', 1, 6),
((SELECT id FROM category WHERE slug='desserts'), 'MC Fleury', 440, 100, 'assets/images/produits/desserts/mcfleury.png', 1, 7),
((SELECT id FROM category WHERE slug='desserts'), 'Muffin', 360, 100, 'assets/images/produits/desserts/muffin.png', 1, 8),
((SELECT id FROM category WHERE slug='desserts'), 'Sunday', 100, 100, 'assets/images/produits/desserts/sunday.png', 1, 9);
-- 2.h sauces
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='sauces'), 'Classic Barbecue', 70, 100, 'assets/images/produits/sauces/classic-barbecue.png', 1, 1),
((SELECT id FROM category WHERE slug='sauces'), 'Classic Moutarde', 70, 100, 'assets/images/produits/sauces/classic-moutarde.png', 1, 2),
((SELECT id FROM category WHERE slug='sauces'), 'Creamy Deluxe', 70, 100, 'assets/images/produits/sauces/cremy-deluxe.png', 1, 3),
((SELECT id FROM category WHERE slug='sauces'), 'Ketchup', 70, 100, 'assets/images/produits/sauces/ketchup.png', 1, 4),
((SELECT id FROM category WHERE slug='sauces'), 'Chinoise', 70, 100, 'assets/images/produits/sauces/sauce-chinoise.png', 1, 5),
((SELECT id FROM category WHERE slug='sauces'), 'Curry', 70, 100, 'assets/images/produits/sauces/sauce-curry.png', 1, 6),
((SELECT id FROM category WHERE slug='sauces'), 'Pommes Frites', 70, 100, 'assets/images/produits/sauces/sauce-pommes-frite.png', 1, 7);
-- -----------------------------------------------------------------------------
-- 3. menu (13) — the "menus" category items.
-- category_id = the menus category.
-- burger_product_id resolved by matching the anchor burger name
-- ("Menu Le 280" -> product "Le 280", etc.).
-- price_normal_cents from source; price_maxi_cents = normal + 150.
-- -----------------------------------------------------------------------------
INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, price_maxi_cents, image_path, is_available, display_order) VALUES
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Le 280'), 'Menu Le 280', 880, 1030, 'assets/images/produits/burgers/280.png', 1, 1),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty'), 'Menu Big Tasty', 1060, 1210, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty Bacon'), 'Menu Big Tasty Bacon', 1090, 1240, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Mac'), 'Menu Big Mac', 800, 950, 'assets/images/produits/burgers/bigmac.png', 1, 4),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='CBO'), 'Menu CBO', 1090, 1240, 'assets/images/produits/burgers/cbo.png', 1, 5),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Chicken'), 'Menu MC Chicken', 930, 1080, 'assets/images/produits/burgers/mcchicken.png', 1, 6),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Crispy'), 'Menu MC Crispy', 720, 870, 'assets/images/produits/burgers/mccrispy.png', 1, 7),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Fish'), 'Menu MC Fish', 720, 870, 'assets/images/produits/burgers/mcfish.png', 1, 8),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Bacon'), 'Menu Royal Bacon', 705, 855, 'assets/images/produits/burgers/royalbacon.png', 1, 9),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Cheese'), 'Menu Royal Cheese', 640, 790, 'assets/images/produits/burgers/royalcheese.png', 1, 10),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Deluxe'), 'Menu Royal Deluxe', 740, 890, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), 'Menu Signature BBQ Beef 2 viandes', 1350, 1500, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12),
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature Beef BBQ'), 'Menu Signature Beef BBQ', 1190, 1340, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13);
-- -----------------------------------------------------------------------------
-- 4. menu_slot — three standard slots per menu:
-- drink (required), side (required), sauce (optional).
-- One INSERT per slot_type, fanning out over all 13 menus via SELECT.
-- -----------------------------------------------------------------------------
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
SELECT m.id, 'Boisson', 'drink', 1, 1
FROM menu m
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
SELECT m.id, 'Accompagnement', 'side', 1, 2
FROM menu m
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
SELECT m.id, 'Sauce', 'sauce', 0, 3
FROM menu m
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
-- -----------------------------------------------------------------------------
-- 5. menu_slot_option — eligible products per slot:
-- drink slot -> all products in category 'boissons'
-- side slot -> all products in category 'frites'
-- sauce slot -> all products in category 'sauces'
-- Composite PK (menu_slot_id, product_id) is naturally satisfied: each
-- (slot, product) pair is unique because slots are unique per menu.
-- -----------------------------------------------------------------------------
INSERT INTO menu_slot_option (menu_slot_id, product_id)
SELECT ms.id, p.id
FROM menu_slot ms
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='boissons')
WHERE ms.slot_type = 'drink';
INSERT INTO menu_slot_option (menu_slot_id, product_id)
SELECT ms.id, p.id
FROM menu_slot ms
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='frites')
WHERE ms.slot_type = 'side';
INSERT INTO menu_slot_option (menu_slot_id, product_id)
SELECT ms.id, p.id
FROM menu_slot ms
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='sauces')
WHERE ms.slot_type = 'sauce';

View file

@ -229,7 +229,7 @@ Reseaux :
- Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h)
- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles)
- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC
- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire
- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)

View file

@ -42,7 +42,7 @@ multi-canal. Chaque acteur candidat est confronte au perimetre reel.
| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. |
| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. |
| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). |
| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". |
| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), utilisateurs en lecture seule (`user.read`) et sans acces au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". |
| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". |
| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. |

22
phpstan.neon Normal file
View file

@ -0,0 +1,22 @@
# Analyse statique sans Composer : lancee via le .phar autonome
# (php phpstan.phar analyse --no-progress --error-format=raw).
# Aucune baseline, aucun vendor/ : colle au "from scratch" (PROJECT_CONTEXT.md decision 4).
parameters:
level: 6
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
# Les classes de PHPUnit (TestCase, ...) vivent dans le .phar autonome, hors
# de src/. On les expose a PHPStan en scannant le phar telecharge par la CI.
# Si phpunit.phar est absent (analyse de src/ seul en local), la ligne est
# sans effet : on neutralise alors le bruit "classe inconnue" cote tests.
scanDirectories:
- phar://phpunit.phar
ignoreErrors:
# Tolere l'absence de phpunit.phar en local : les symboles PHPUnit ne
# sont alors pas resolus. En CI le phar est present, l'analyse est complete.
-
identifier: class.notFound
path: tests/*
reportUnmatched: false

26
phpunit.xml Normal file
View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Configuration PHPUnit sans Composer : le bootstrap charge l'autoloader manuel
du Core (PSR-4 maison). Lance via le .phar autonome (php phpunit.phar -c phpunit.xml),
conformement a la stack lockee (PROJECT_CONTEXT.md section 6 : tests via .phar).
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Throwable;
use App\Core\Controller;
use App\Core\Response;
/**
* Sonde de sante. GET /api/health.
*
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
*/
final class HealthController extends Controller
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$dbStatus = 'ok';
$categories = null;
$httpStatus = 200;
try {
$row = $this->database->fetch('SELECT COUNT(*) AS total FROM category');
$categories = (int) ($row['total'] ?? 0);
} catch (Throwable) {
// Detail de l'erreur volontairement non expose (information disclosure) ;
// un statut degrade suffit a la sonde, les logs conteneur portent le reste.
$dbStatus = 'error';
$httpStatus = 503;
}
return $this->json(
[
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
'app_env' => $this->config->appEnv(),
'php_version' => PHP_VERSION,
'db' => $dbStatus,
'categories' => $categories,
],
$httpStatus,
);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Response;
/**
* Page d'accueil du back-office. GET /.
*
* Volontairement minimale en P2 : prouve que le rendu de vue MVC traverse
* controleur -> vue -> layout sans dependre de la BDD.
*/
final class HomeController extends Controller
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
return $this->view('home', [
'title' => 'Wakdo back-office',
'appEnv' => $this->config->appEnv(),
]);
}
}

43
src/Core/Autoloader.php Normal file
View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* PSR-4 autoloader manuel, sans Composer (exigence "from scratch" Cr 4.c.3).
*
* Mappe le prefixe de namespace racine "App\" sur le dossier src/.
* Exemple : App\Core\Router -> {src}/Core/Router.php
*/
final class Autoloader
{
private const PREFIX = 'App\\';
/**
* Enregistre l'autoloader aupres de la pile SPL.
*
* La racine src/ est calculee depuis l'emplacement de ce fichier
* (src/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/.
* Aucun chemin code en dur, donc portable host/conteneur.
*/
public static function register(): void
{
$root = dirname(__DIR__);
spl_autoload_register(static function (string $class) use ($root): void {
if (!str_starts_with($class, self::PREFIX)) {
return;
}
$relative = substr($class, strlen(self::PREFIX));
$path = $root . DIRECTORY_SEPARATOR
. str_replace('\\', DIRECTORY_SEPARATOR, $relative)
. '.php';
if (is_file($path)) {
require $path;
}
});
}
}

80
src/Core/Config.php Normal file
View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Core;
use RuntimeException;
/**
* Acces type a la configuration, lue depuis les variables d'environnement.
*
* Pas de parsing .env : en conteneur le .env n'est pas monte, les valeurs
* sont injectees par docker-compose / l'environnement (getenv).
*/
final class Config
{
public function get(string $key, ?string $default = null): ?string
{
$value = getenv($key);
// getenv renvoie false si absent ; une chaine vide est traitee comme absente
// car les variables d'env vides n'apportent pas d'information exploitable.
if ($value === false || $value === '') {
return $default;
}
return $value;
}
/**
* Lit une valeur obligatoire ; echoue tot si la config est incomplete
* plutot que de laisser une erreur survenir plus loin (fail-fast).
*/
public function required(string $key): string
{
$value = $this->get($key);
if ($value === null) {
throw new RuntimeException(sprintf('Missing required configuration: %s', $key));
}
return $value;
}
public function int(string $key, int $default = 0): int
{
$value = $this->get($key);
return $value === null ? $default : (int) $value;
}
/**
* Interprete les conventions usuelles de booleen textuel d'environnement.
*/
public function bool(string $key, bool $default = false): bool
{
$value = $this->get($key);
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public function appEnv(): string
{
return $this->get('APP_ENV', 'production') ?? 'production';
}
public function isDebug(): bool
{
return $this->bool('APP_DEBUG', false);
}
public function timezone(): string
{
return $this->get('APP_TIMEZONE', 'UTC') ?? 'UTC';
}
}

68
src/Core/Controller.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Core;
use RuntimeException;
/**
* Controleur de base. Toute la hierarchie de controleurs en herite
* (BaseController -> ProductController, etc., demonstration heritage Cr 4.c.1).
*
* Recoit ses dependances par constructeur : la requete courante, la config et
* l'acces BDD, injectes par le Router.
*/
abstract class Controller
{
public function __construct(
protected readonly Request $request,
protected readonly Config $config,
protected readonly Database $database,
) {
}
/**
* @param array<string|int, mixed> $data
*/
protected function json(array $data, int $status = 200): Response
{
return (new Response())->json($data, $status);
}
/**
* Rend une vue PHP sous src/Views/<name>.php avec ses donnees extraites.
*
* Le rendu est bufferise puis injecte dans le layout via la variable
* $content, ce qui permet aux vues de rester de simples fragments.
*
* @param array<string, mixed> $data
*/
protected function view(string $name, array $data = [], int $status = 200): Response
{
$content = $this->render($name, $data);
$html = $this->render('layout', $data + ['content' => $content]);
return (new Response())->html($html, $status);
}
/**
* @param array<string, mixed> $data
*/
private function render(string $name, array $data): string
{
$file = dirname(__DIR__) . '/Views/' . $name . '.php';
if (!is_file($file)) {
throw new RuntimeException(sprintf('View not found: %s', $name));
}
// Les cles deviennent des variables locales a la vue ; le buffering
// capture le HTML produit sans l'emettre directement.
extract($data, EXTR_SKIP);
ob_start();
require $file;
return (string) ob_get_clean();
}
}

94
src/Core/Database.php Normal file
View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOStatement;
/**
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
*
* Connexion paresseuse : le PDO n'est ouvert qu'au premier acces afin que les
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
* indisponible.
*/
final class Database
{
private ?PDO $pdo = null;
public function __construct(private readonly Config $config)
{
}
private function pdo(): PDO
{
if ($this->pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$this->config->required('DB_HOST'),
$this->config->int('DB_PORT', 3306),
$this->config->required('DB_NAME'),
);
$this->pdo = new PDO(
$dsn,
$this->config->required('DB_USER'),
$this->config->required('DB_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Vraies requetes preparees cote serveur (pas d'emulation) :
// le SQL et les valeurs voyagent separement, fermant l'injection.
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}
return $this->pdo;
}
/**
* Prepare puis execute une requete avec ses parametres lies.
*
* @param array<string|int, mixed> $params
*/
public function query(string $sql, array $params = []): PDOStatement
{
$statement = $this->pdo()->prepare($sql);
$statement->execute($params);
return $statement;
}
/**
* @param array<string|int, mixed> $params
* @return array<string, mixed>|null
*/
public function fetch(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row === false ? null : $row;
}
/**
* @param array<string|int, mixed> $params
* @return array<int, array<string, mixed>>
*/
public function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
/**
* Execute une ecriture et renvoie le nombre de lignes affectees.
*
* @param array<string|int, mixed> $params
*/
public function execute(string $sql, array $params = []): int
{
return $this->query($sql, $params)->rowCount();
}
}

145
src/Core/Request.php Normal file
View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Representation immuable de la requete HTTP entrante.
*
* Construite depuis les super-globales par fromGlobals() ; le reste de
* l'application ne touche jamais $_SERVER / $_GET directement.
*/
final class Request
{
/**
* @param array<string, string> $query
* @param array<string, string> $headers
*/
public function __construct(
private readonly string $method,
private readonly string $path,
private readonly array $query,
private readonly array $headers,
private readonly string $rawBody,
) {
}
public static function fromGlobals(): self
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
// REQUEST_URI inclut la query string ; on isole le chemin seul.
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH);
$path = is_string($path) ? $path : '/';
$path = self::normalizePath($path);
/** @var array<string, string> $query */
$query = $_GET;
return new self(
$method,
$path,
$query,
self::extractHeaders(),
(string) file_get_contents('php://input'),
);
}
/**
* Garde un slash de tete et retire le slash de fin (sauf racine) pour
* que "/api/health/" et "/api/health" matchent la meme route.
*/
private static function normalizePath(string $path): string
{
if ($path === '') {
return '/';
}
if ($path[0] !== '/') {
$path = '/' . $path;
}
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
return $path;
}
/**
* @return array<string, string>
*/
private static function extractHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', substr($key, 5));
$headers[strtolower($name)] = (string) $value;
}
}
// Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP.
if (isset($_SERVER['CONTENT_TYPE'])) {
$headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
}
if (isset($_SERVER['CONTENT_LENGTH'])) {
$headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
}
return $headers;
}
public function method(): string
{
return $this->method;
}
public function path(): string
{
return $this->path;
}
public function query(string $key, ?string $default = null): ?string
{
return $this->query[$key] ?? $default;
}
/**
* @return array<string, string>
*/
public function allQuery(): array
{
return $this->query;
}
public function header(string $name, ?string $default = null): ?string
{
return $this->headers[strtolower($name)] ?? $default;
}
public function rawBody(): string
{
return $this->rawBody;
}
/**
* Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou
* invalide, pour laisser la validation metier decider (pas de fatale ici).
*
* @return array<string, mixed>
*/
public function json(): array
{
if ($this->rawBody === '') {
return [];
}
$decoded = json_decode($this->rawBody, true);
return is_array($decoded) ? $decoded : [];
}
}

96
src/Core/Response.php Normal file
View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Reponse HTTP accumulee puis emise par send().
*
* Permet de construire entierement la reponse avant tout echo, ce qui rend
* le front controller testable et evite les "headers already sent".
*/
final class Response
{
/** @var array<string, string> */
private array $headers = [];
private string $body = '';
public function __construct(private int $status = 200)
{
}
public function setStatus(int $status): self
{
$this->status = $status;
return $this;
}
public function status(): int
{
return $this->status;
}
public function setHeader(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
public function setBody(string $body): self
{
$this->body = $body;
return $this;
}
/**
* @param array<string, string> $headers
*/
public static function make(string $body, int $status, array $headers): self
{
$response = new self($status);
$response->body = $body;
foreach ($headers as $name => $value) {
$response->setHeader($name, $value);
}
return $response;
}
/**
* @param array<string|int, mixed> $data
*/
public function json(array $data, int $status = 200): self
{
$this->status = $status;
$this->setHeader('Content-Type', 'application/json; charset=utf-8');
$this->body = (string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $this;
}
public function html(string $body, int $status = 200): self
{
$this->status = $status;
$this->setHeader('Content-Type', 'text/html; charset=utf-8');
$this->body = $body;
return $this;
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $this->body;
}
}

104
src/Core/Router.php Normal file
View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Routeur a base d'expressions regulieres compilees.
*
* Les patterns acceptent des segments dynamiques {param} compiles en groupes
* nommes. Le dispatch distingue 404 (aucun chemin ne correspond) de 405
* (le chemin correspond mais pas la methode).
*/
final class Router
{
/**
* @var array<int, array{method: string, regex: string, handler: array{0: class-string, 1: string}}>
*/
private array $routes = [];
public function __construct(
private readonly Config $config,
private readonly Database $database,
) {
}
/**
* @param array{0: class-string, 1: string} $handler [ControllerClass::class, 'action']
*/
public function add(string $method, string $pattern, array $handler): self
{
$this->routes[] = [
'method' => strtoupper($method),
'regex' => $this->compile($pattern),
'handler' => $handler,
];
return $this;
}
/**
* Traduit "/api/orders/{number}" en une regex ancree avec groupes nommes.
* Les segments litteraux sont echappes pour neutraliser tout metacaractere.
*/
private function compile(string $pattern): string
{
$regex = preg_replace_callback(
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$pattern,
);
// preg_quote n'est pas applicable globalement (il echapperait les groupes
// generes) ; les patterns sont des litteraux de route controles, donc on
// se contente de figer les delimiteurs avec un delimiteur improbable.
return '#^' . $regex . '$#';
}
/**
* Resout la requete : instancie le controleur et appelle l'action avec les
* parametres de route extraits, ou renvoie une reponse 404 / 405.
*/
public function dispatch(Request $request): Response
{
$pathMatched = false;
foreach ($this->routes as $route) {
if (preg_match($route['regex'], $request->path(), $matches) !== 1) {
continue;
}
$pathMatched = true;
if ($route['method'] !== $request->method()) {
continue;
}
$params = array_filter(
$matches,
static fn (int|string $key): bool => is_string($key),
ARRAY_FILTER_USE_KEY,
);
[$controllerClass, $action] = $route['handler'];
/** @var Controller $controller */
$controller = new $controllerClass($request, $this->config, $this->database);
return $controller->$action($params);
}
if ($pathMatched) {
return (new Response())->json(
['data' => null, 'error' => ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Method not allowed']],
405,
);
}
return (new Response())->json(
['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Resource not found']],
404,
);
}
}

25
src/Views/home.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* Fragment de la page d'accueil back-office, injecte dans layout.php.
*
* @var string $appEnv
*/
$env = htmlspecialchars($appEnv ?? 'unknown', ENT_QUOTES, 'UTF-8');
?>
<main>
<h1>Wakdo back-office</h1>
<p>Le squelette back-end (P2) est en ligne.</p>
<p>
<small>
Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements.
Environnement : <code><?= $env ?></code>.
</small>
</p>
<p>
<small>Sonde de sante : <code>GET /api/health</code></small>
</p>
</main>

32
src/Views/layout.php Normal file
View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* Gabarit HTML5 du back-office. Recoit $title et $content depuis le controleur.
* Les variables sont fournies par Controller::view() via extract().
*
* @var string $title
* @var string $content
*/
$pageTitle = htmlspecialchars($title ?? 'Wakdo', ENT_QUOTES, 'UTF-8');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Back-office prive : jamais indexe par les moteurs de recherche. -->
<meta name="robots" content="noindex, nofollow">
<title><?= $pageTitle ?></title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; line-height: 1.5; }
h1 { font-size: 1.5rem; }
small { color: #666; }
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
</head>
<body>
<?= $content ?? '' ?>
</body>
</html>

View file

@ -1,34 +1,53 @@
<?php
declare(strict_types=1);
// Stub pour debloquer le routage Apache + valider la chaine FastCGI vers PHP-FPM.
// Sera remplace par le front controller MVC en phase P2 (src/Core/Router.php a venir).
/**
* Front controller du vhost admin (back-office + API sous /api).
*
* Apache reecrit toute requete non-fichier vers ce fichier (RewriteRule ^ index.php).
* Le REQUEST_URI arrive intact (pas de prefixe strippe), donc le routeur voit
* "/", "/api/health", etc.
*/
header('Content-Type: text/html; charset=utf-8');
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Core\Router;
// src/public/admin/index.php : __DIR__ = src/public/admin ; remonter de deux
// niveaux (admin -> public -> src) pour atteindre la racine src/.
require dirname(__DIR__, 2) . '/Core/Autoloader.php';
Autoloader::register();
// En-tetes de securite poses tot, valables sur toute reponse y compris une 500.
header('X-Content-Type-Options: nosniff');
header('X-Robots-Tag: noindex, nofollow');
$phpVersion = htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8');
$now = htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Wakdo - back-office</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
img { max-height: 80px; }
small { color: #666; }
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
</head>
<body>
<h1>Wakdo - back-office</h1>
<p>En construction.</p>
<p><small>Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.</small></p>
<hr>
<p><small>Diagnostic FastCGI : PHP <code><?= $phpVersion ?></code> repond a <code><?= $now ?></code>.</small></p>
<p><small>TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.</small></p>
</body>
</html>
$config = new Config();
date_default_timezone_set($config->timezone());
try {
// Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(),
// donc la home back-office reste servie meme base indisponible.
$database = new Database($config);
$router = new Router($config, $database);
$router->add('GET', '/', [HomeController::class, 'index']);
$router->add('GET', '/api/health', [HealthController::class, 'index']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {
// En debug on remonte le message pour iterer ; en prod, reponse generique
// pour ne rien divulguer de la pile interne (information disclosure).
$payload = $config->isDebug()
? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]]
: ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']];
(new Response())->json($payload, 500)->send();
}

125
tests/Unit/ConfigTest.php Normal file
View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use App\Core\Config;
/**
* La config lit getenv (pas de .env en conteneur). Les tests pilotent
* l'environnement via putenv et nettoient apres eux pour ne pas polluer les
* autres cas.
*/
final class ConfigTest extends TestCase
{
private Config $config;
/** @var list<string> */
private array $touchedKeys = [];
protected function setUp(): void
{
$this->config = new Config();
}
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);
}
public function testGetReturnsValueWhenPresent(): void
{
$this->setEnv('WAKDO_TEST_NAME', 'borne');
self::assertSame('borne', $this->config->get('WAKDO_TEST_NAME'));
}
public function testGetReturnsDefaultWhenAbsent(): void
{
self::assertSame('fallback', $this->config->get('WAKDO_TEST_MISSING', 'fallback'));
self::assertNull($this->config->get('WAKDO_TEST_MISSING'));
}
public function testGetTreatsEmptyStringAsAbsent(): void
{
// Une variable d'env vide n'apporte pas d'information : Config la traite
// comme absente et renvoie le defaut (contrat documente dans Config::get).
$this->setEnv('WAKDO_TEST_EMPTY', '');
self::assertSame('def', $this->config->get('WAKDO_TEST_EMPTY', 'def'));
}
public function testIntCastsValue(): void
{
$this->setEnv('WAKDO_TEST_PORT', '3307');
self::assertSame(3307, $this->config->int('WAKDO_TEST_PORT'));
}
public function testIntReturnsDefaultWhenAbsent(): void
{
self::assertSame(3306, $this->config->int('WAKDO_TEST_PORT_MISSING', 3306));
}
/**
* @return list<array{0: string, 1: bool}>
*/
public static function truthyValuesProvider(): array
{
return [
['1', true],
['true', true],
['TRUE', true],
['yes', true],
['on', true],
['0', false],
['false', false],
['no', false],
['off', false],
['anything-else', false],
];
}
#[DataProvider('truthyValuesProvider')]
public function testBoolInterpretsCommonConventions(string $raw, bool $expected): void
{
$this->setEnv('WAKDO_TEST_FLAG', $raw);
self::assertSame($expected, $this->config->bool('WAKDO_TEST_FLAG'));
}
public function testBoolReturnsDefaultWhenAbsent(): void
{
self::assertTrue($this->config->bool('WAKDO_TEST_FLAG_MISSING', true));
self::assertFalse($this->config->bool('WAKDO_TEST_FLAG_MISSING', false));
}
public function testRequiredReturnsValueWhenPresent(): void
{
$this->setEnv('WAKDO_TEST_DB', 'wakdo');
self::assertSame('wakdo', $this->config->required('WAKDO_TEST_DB'));
}
public function testRequiredThrowsWhenMissing(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Missing required configuration: WAKDO_TEST_REQUIRED_MISSING');
$this->config->required('WAKDO_TEST_REQUIRED_MISSING');
}
}

123
tests/Unit/RouterTest.php Normal file
View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Core\Config;
use App\Core\Controller;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Core\Router;
/**
* Controleur sonde : capture les parametres de route recus pour prouver
* l'extraction du segment {id} par le Router. Etend le vrai Controller du Core
* pour traverser le meme chemin d'instanciation que la production.
*
* @param array<string, string> $params
*/
final class RouteProbeController extends Controller
{
/** @var array<string, string> */
public static array $capturedParams = [];
/**
* @param array<string, string> $params
*/
public function show(array $params = []): Response
{
self::$capturedParams = $params;
return (new Response())->json(['data' => $params], 200);
}
}
final class RouterTest extends TestCase
{
private Config $config;
private Database $database;
protected function setUp(): void
{
$this->config = new Config();
// Le PDO est paresseux (ouvert au premier acces), donc construire la
// Database ne tente aucune connexion : aucune BDD requise pour ces tests.
$this->database = new Database($this->config);
RouteProbeController::$capturedParams = [];
}
/**
* Fabrique une Request sans toucher aux super-globales : le constructeur
* de Request est public, on injecte directement methode et chemin.
*/
private function request(string $method, string $path): Request
{
return new Request($method, $path, [], [], '');
}
private function router(): Router
{
return new Router($this->config, $this->database);
}
public function testMatchedRouteExtractsNamedParam(): void
{
$router = $this->router();
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
$response = $router->dispatch($this->request('GET', '/api/orders/42'));
self::assertSame(200, $response->status());
self::assertSame(['id' => '42'], RouteProbeController::$capturedParams);
}
public function testMultipleParamsAreAllExtracted(): void
{
$router = $this->router();
$router->add('GET', '/api/menus/{menu}/options/{option}', [RouteProbeController::class, 'show']);
$response = $router->dispatch($this->request('GET', '/api/menus/7/options/maxi'));
self::assertSame(200, $response->status());
self::assertSame(['menu' => '7', 'option' => 'maxi'], RouteProbeController::$capturedParams);
}
public function testUnknownPathReturns404(): void
{
$router = $this->router();
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
$response = $router->dispatch($this->request('GET', '/api/nope'));
self::assertSame(404, $response->status());
self::assertSame([], RouteProbeController::$capturedParams);
}
public function testKnownPathWrongMethodReturns405(): void
{
$router = $this->router();
// Seul GET est enregistre sur ce chemin ; un POST matche le chemin mais
// pas la methode, ce qui doit produire 405 et non 404.
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
$response = $router->dispatch($this->request('POST', '/api/orders/42'));
self::assertSame(405, $response->status());
self::assertSame([], RouteProbeController::$capturedParams);
}
public function testMethodMatchingIsCaseInsensitiveOnRegistration(): void
{
$router = $this->router();
// add() normalise la methode en majuscules ; une route "get" doit donc
// repondre a une requete GET.
$router->add('get', '/api/health', [RouteProbeController::class, 'show']);
$response = $router->dispatch($this->request('GET', '/api/health'));
self::assertSame(200, $response->status());
}
}

13
tests/bootstrap.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* Amorce PHPUnit sans Composer : on charge l'autoloader manuel du Core puis on
* l'enregistre, exactement comme le fait le front controller en production
* (src/public/admin/index.php). Les tests resolvent ainsi App\... via PSR-4.
*/
require __DIR__ . '/../src/Core/Autoloader.php';
App\Core\Autoloader::register();