# Documentation technique QRamen

État de référence : **26 octobre 2026 à 22 h (Europe/Paris)**, d’après les faits de production fournis pour la soutenance et les sources du dépôt. Les contrôles historiques sont distingués des vérifications réalisées pour cette mise à jour. Restaurant fictif : QRamen, ramen et cuisine japonaise de comptoir à Rouen. Sources principales : `src/data/`, `src/features/parcours/client.ts`, `outils/airtable/` et les rapports dans `docs/`.

Site en ligne : **[qramen.fr](https://qramen.fr)**, hébergé sur Cloudflare Pages, projet `qramen-soutenance`, domaine acheté chez Amen et délégué à Cloudflare (en service le 26 octobre 2026 à 22 h 50 ; `qramen-soutenance.pages.dev` reste un accès de secours).

## Architecture réelle

Astro 7.4.1 produit un site statique, sans adaptateur serveur. Les interactions métier sont écrites en TypeScript et exécutées dans le navigateur ; aucun framework d’interface supplémentaire n’est installé. Dix pages Astro sont présentes, styleguide compris, dont deux écrans du personnel (`/salle`) et une planche de QR imprimable (`/qr`). Le Worker Cloudflare ajoute le proxy `/api/admin/*` et protège `/panel`, `/cuisine` et `/salle` par rôle ; les quatorze webhooks n8n ne sont pas des pages Astro.

```text
Airtable : categories, produits, tables_salle
    | lecture authentifiée, synchronisation manuelle avant build
    v
outils/airtable/sync.mjs -> snapshot facultatif -> menu.ts <- fixtures
                                                    |
                                               src/data/api.ts
                                                    |
Astro build -> Cloudflare Pages + Worker -> navigateur
    client : commande / avis ----------------------> n8n -> Airtable
    panel / cuisine -> Basic Auth -> /api/admin/* --> n8n -> Airtable
                                     X-Gerant-Key      |
                                   ajouté côté serveur +-> tableqr-pdf -> commandes.ticket

localStorage : panier, thème, cache métier et repli « Mode démo locale »
Softr -> Astro : écart assumé ; interfaces client, gérant et cuisine en Astro statique.
```

La synchronisation n’est **pas déclenchée par `npm run build`** : il faut la lancer explicitement. Le script lit trois tables, pagine, valide puis remplace atomiquement le snapshot. Une erreur laisse le dernier snapshot intact et termine avec le code 1. `menu.ts` charge ce fichier facultatif : absent, mal formé ou invalide, il revient intégralement aux fixtures. Le build peut donc fonctionner sans réseau ni token. Un snapshot valide ancien reste utilisé : aucune expiration automatique.

`menu.ts` fournit le catalogue ; `api.ts` centralise les opérations métier (`listProducts`, `listCategories`, `listTables`, `listOrders`, `updateProduct`, `createOrder`, `setStatus`, `createReview`). Ce nom ne désigne pas une API HTTP. L’accueil importe aussi ses données éditoriales depuis `menu.ts` ; le produit vedette reste local. Les descriptions viennent du snapshot Airtable ; les allergènes et alternatives sont repris des fixtures par correspondance de nom, ainsi que les identifiants locaux correspondants.

## Automatisations réellement utilisées

**Quatorze workflows n8n actifs** remplacent Make : les dix de production (`commandes`, `catalogue`, `commande`, `statut`, `rupture`, `ticket`, `confirmation`, `avis`, `produits`, `import carte`) plus la `carte` publique en lecture seule, les `parametres` du back-office et l'`assistant` de descriptions et l'upload de `photo`. Le remplacement de Make prévu dans la fiche validée est un écart assumé : n8n auto-hébergé évite le plafond de crédits Make, avec des coûts et ressources VPS à gérer. Aucun coût économisé ni volume d’exécutions mesuré n’est revendiqué. Voir [ECARTS-ASSUMES.md](ECARTS-ASSUMES.md). Les définitions et scripts de workflows sont versionnés dans `outils/n8n/`.

Les chemins actuels sont `/webhook/qramen-soutenance/<action>` ; les anciens chemins `tableqr/*` des notices historiques ne décrivent plus les points d’entrée actuels.

| Accès | Action | Méthode | Traitement |
| --- | --- | --- | --- |
| Public | `commande` | POST | Commande et lignes dans Airtable, idempotence ; déclenchement serveur du ticket |
| Public | `avis` | POST | Enregistrement de l’avis ; alerte prévue pour une note ≤ 3, e-mail non fonctionnel |
| Administration | `commandes` | POST | Lecture des commandes pour le suivi cuisine |
| Administration | `catalogue` | GET | Lecture des produits et catégories Airtable |
| Administration | `statut` | POST | Mise à jour du statut de commande |
| Administration | `rupture` | POST | Disponibilité du produit ; alerte e-mail non fonctionnelle |
| Administration | `ticket` | POST | PDF cuisine joint à `commandes.ticket`, idempotent |
| Administration | `confirmation` | POST | Récapitulatif e-mail, envoi non fonctionnel |
| Administration | `produits` | POST | Création des produits importés et liens catégories |
| Administration | `importer-carte` | POST | Analyse de photo/PDF avec Gemini |

Le ticket est produit par le service local **`tableqr-pdf`**, à `172.19.0.1:8798`, avec **fpdf2 et Liberation Sans**, protégé par une clé dédiée. Il remplace CraftMyPDF/PDF.co. Le workflow commande le déclenche côté serveur : le client public n’a pas à appeler le webhook administratif `ticket`. Le workflow vérifie le ticket déjà joint pour éviter de le générer à nouveau ; cela ne constitue pas une preuve d’unicité transactionnelle sous concurrence.

### Import de carte et catalogue

Le parcours de production fourni accepte une photo **JPEG/PNG/WEBP ou un PDF, 15 Mo maximum** : envoi au workflow n8n `importer-carte`, analyse par Gemini (`gemini-flash-lite-latest`), prévisualisation éditable, puis validation explicite des lignes choisies. Le webhook `produits` reçoit des lots de **30 produits maximum** ; les catégories sont liées à Airtable et une catégorie inconnue est signalée dans la réponse (`sansCategorie`). Le panel relit ensuite le catalogue via GET `catalogue`.

**Écart du dépôt à vérifier :** `src/data/import-card.ts` accepte actuellement JPEG, PNG et PDF, mais refuse WEBP. Le support WEBP de production est un fait fourni, pas une vérification locale de ce validateur. Aucun code front n’est modifié par ce bloc documentaire.

Le **prompt de l'IA est versionné dans le dépôt** : `outils/n8n/prompt-import-carte.txt` (version 2 du 26 octobre 2026), injecté dans le nœud « Preparer le fichier » du workflow d'import par `outils/n8n/prompt-import-carte.py`. La règle de catégorisation y est écrite noir sur blanc : le modèle doit classer chaque plat dans l'une des six catégories existantes (Ramen, Donburi, Entrées, Accompagnements, Desserts, Boissons) selon une définition par type de plat, et ne créer une catégorie nouvelle qu'en dernier recours, catégorie qui sera signalée au gérant avant écriture. Vérification du 26 octobre : sans cette règle, le modèle rangeait les trois plats de la carte de test dans une catégorie inventée « Carte » ; avec la version 2, il rend « Ramen » et « Accompagnements ». Trois modèles ont été comparés le même soir sur la même carte et le même prompt : `gemini-flash-lite-latest` (3,7 s), `gemini-flash-latest` (plus de 100 s, sortie identique) et `gemini-pro-latest` (quota gratuit à zéro, 429). Le modèle « lite » est retenu et le modèle utilisé est désormais lisible dans la réponse du webhook. Détail : `RAPPORT-COMPARAISON-MODELES.md`. Les preuves de réponse sont conservées dans `docs/import-carte/preuve-import-v2.json` et `preuve-comparaison-modeles.json`.

La carte de référence compte **40 produits, six catégories, 40 visuels de plats**. Ces visuels sont des illustrations générées (méthode et provenance : `docs/RAPPORT-IMAGES.md`) : aucune n'est une photographie prise dans le restaurant. Deux produits portent en plus une pièce jointe réelle dans le champ `photo` d'Airtable.

Les e-mails **fonctionnent** depuis le test réel du 26 octobre 2026 à 22 h 32 : LWS refusait l’authentification depuis l’IP du VPS, la chaîne est passée chez **Amen** (`smtp-fr.securemail.pro`, port 465, compte `contact@qramen.fr`) et l’envoi a réussi. Le domaine `qramen.fr` est acheté et en service. Aucun envoi n’est pour autant garanti à chaque exécution : la preuve est l’envoi de test conservé, pas l’état « actif » du workflow. Les notices et rapports antérieurs restent des traces de leur étape de développement, à lire avec cette mise à jour.

## Modèle de données : sept tables métier, plus une table de comptes

Le dépôt ne contient ni export du schéma Airtable complet ni migration définissant les sept tables métier. La huitième table, `utilisateurs` (email, nom, rôle gérant/cuisine/salle), sert à la synchronisation des comptes de l'application Softr du personnel ; l'option de paiement de démonstration est un champ `paiement_actif` de la table `etablissements`. Le tableau suivant recense les principaux champs attestés par le code ou les notices, sans dictionnaire exhaustif, sans présenter des types déduits comme des métadonnées Airtable vérifiées. `id` désigne l’identifiant technique du record, pas un champ métier. Les champs inverses des relations ne sont pas exportés.

| Table | Champs et types attestés ou attendus | Relations et état |
| --- | --- | --- |
| `etablissements` | Aucun champ ni type documenté | Table nommée dans `AIRTABLE.md`, non lue ; liens éventuels à confirmer |
| `categories` | `nom` : texte non vide ; `position` : entier ≥ 0 | Cible de `produits.categorie` ; contrat validé par le synchroniseur |
| `produits` | `nom` : texte ; `description` : chaîne, éventuellement vide ; `categorie` : liste d’identifiants liés ou texte accepté ; `prix` : nombre fini > 0 ; `disponible` : booléen, checkbox absente → faux ; `photo_url` : texte HTTPS ou chemin `img/`, ou null ; `position` : entier ≥ 0 | Relation catégorie résolue sur le premier identifiant ; type Airtable exact du prix non exporté |
| `tables_salle` | `code` : texte, ou `nom` en repli ; `position` : entier ≥ 0, sinon extrait du code | Référencée comme `table` dans les commandes de la notice ; configuration du lien à confirmer |
| `commandes` | Ancienne notice Softr (historique Git) : `nom`, `table`, `statut`, `total`, `cree_le`, `lignes_commande`. Types attendus : texte, lien table, sélection de statut, nombre monétaire, date, liens lignes. Workflow n8n ticket : champ de pièce jointe `ticket`, sans dictionnaire complet exporté | Dictionnaire distant exhaustif non exporté ; commandes écrites via n8n depuis le front |
| `lignes_commande` | Workflow n8n commande : commande liée, plats et quantités, sans noms techniques exhaustifs ni types exportés | Liens vers commande et produits attendus ; prix figé uniquement attesté dans le modèle local |
| `avis` | Notices : `note` numérique, commentaire et éventuellement e-mail client, sans noms techniques complets | Relation commande attestée localement par `orderId` ; schéma distant à confirmer |

Relations vérifiées côté adaptateur : `produits.categorie → categories`. Relations métier locales : une commande possède plusieurs lignes, chaque ligne référence un produit par `productId`, une commande référence une table par son code, un avis référence une commande. Les liens Airtable correspondants, ainsi que l’éventuel rattachement à un établissement, restent à exporter pour disposer d’un dictionnaire distant exhaustif. L’ancienne notice Softr, conservée dans l’historique Git, emploie `en cuisine` ; le front et le contrat n8n utilisent `en-cuisine`. Ne pas reprendre le libellé Softr comme valeur technique actuelle.

### Équivalent fixtures et état local

| Source | Structure réelle |
| --- | --- |
| `src/data/menu.fixtures.ts` | `Product` : `id`, `name`, `description`, `category`, `allergens`, `photoAlt` (chaînes), `price` (nombre), `available` (booléen), `photo` (chaîne ou null). 40 produits ; catégories : six chaînes ; tables : douze codes T01 à T12 ; `featuredProduct` éditorial séparé |
| `fixtures/produits.json` | 40 objets `{nom, categorie, prix, disponible, photo, position}` ; catégorie par nom, photo relative à `public/` |
| `fixtures/categories.json` | Six objets `{nom, position}` |
| `fixtures/tables.json` | Douze objets `{nom, position}` ; équivalent de `tables_salle` |
| Snapshot généré | `{version: 1, source: 'airtable', syncedAt, produits, categories, tables}` ; produits `{id, nom, description, categorie, prix, disponible, photo_url, position}` ; catégories et tables `{id, nom, position}` |
| Commandes locales | `Order` : `id`, `number`, `key`, `table`, `createdAt` chaînes ; `ticket?`, `local?` booléens ; `status` : `recue`, `en-cuisine` ou `servie` ; `lines` : `{productId, name, price, quantity}[]` ; `total` nombre arrondi aux centimes |
| Avis locaux | `{orderId: string, rating: number, comment: string}` ; tableaux commandes et avis initialement vides |
| Panier local | `{items: Record<string, number>, table, key: string ou null, orderId?: string}` |

Pas de fixtures pour `etablissements`, ni de JSON initiaux pour commandes, lignes ou avis. L’export `outils/export-airtable/export.mjs` lit bien `menu.fixtures.ts` et génère les trois JSON : `photo` doit être mappé vers `photo_url`, `nom` de table vers `code` si utilisé et les catégories texte vers les relations à l’import. Ces fichiers ne créent pas le schéma Airtable.

## Parcours et états

| Acteur | Routes et états réels |
| --- | --- |
| Client | `/` accueil ; `/menu?t=T07` sélectionne une table connue, sinon paramètre ignoré ; T01 par défaut sans panier conservé. Carte par catégories, produit disponible ou en rupture ; ajout au panier |
| Client | `/panier` vide ou rempli ; quantité de 0 à 99 dans l’interface (0 supprime), retrait, table, total ; validation : panier non vide, table connue, produits disponibles, quantités entières de 1 à 99 dans `createOrder` ; erreur dans la zone de notification |
| Client | `/confirmation?order=<id>` affiche la commande mémorisée après réponse n8n (ou repli local), lignes figées, total et clé ; identifiant absent ou inconnu : aucune commande sur cet appareil |
| Client | `/avis?order=<id>` : commande nécessaire, note obligatoire de 1 à 5, prénom et e-mail facultatif dans le formulaire, commentaire facultatif limité à 2 000 caractères ; transmission au webhook public `avis` ; succès : remerciement ; second avis refusé par la couche métier |
| Gérant | `/panel` : édition du nom, prix positif, disponibilité ; erreurs de formulaire ou métier, notification de succès ; nom et prix encore modifiés localement ; rupture transmise à n8n ; import de carte avec création Airtable et relecture du catalogue |
| Cuisine | `/cuisine` : colonnes `recue → en-cuisine → servie`, état vide possible ; boutons de progression ; son désactivé à chaque ouverture, activation manuelle ; lecture partagée via n8n et rechargement des commandes ; événement `storage` pour les autres onglets du même navigateur |
| Transversal | `/styleguide` : démonstration des composants ; thèmes clair et sombre sur toutes les pages, préférence système initiale et persistance |

## Sécurité : ce qui est protégé, comment et pourquoi

Pour le jury : les clients peuvent ouvrir la carte, commander et laisser un avis sans créer de compte. Les outils du personnel et les actions qui changent la gestion du restaurant demandent, eux, une authentification. La protection comporte **deux niveaux** :

1. **À l’entrée du site**, le Worker Cloudflare impose HTTP Basic sur `/panel`, `/cuisine` et `/api/admin/*`. Il calcule l’empreinte SHA-256 du mot de passe et compare les empreintes à temps constant (boucle sans sortie anticipée). Sans justificatifs valides, il répond **401**. Les routes protégées portent `X-Robots-Tag: noindex, nofollow` et `Cache-Control: no-store`. Ces en-têtes demandent de ne pas indexer ni conserver en cache ; l’authentification assure le contrôle d’accès. Le mécanisme ne crée aucun cookie et ne collecte aucune donnée personnelle nominative : il utilise un compte de démonstration partagé.
2. **À l’entrée des traitements d’administration**, le proxy `/api/admin/*` du même Worker ajoute `X-Gerant-Key` depuis la variable Cloudflare **`GERANT_KEY`**. La valeur reste côté serveur, sans apparaître dans le navigateur. n8n refuse les huit webhooks administratifs sans cette clé avec **HTTP 403**, y compris si quelqu’un tente leur URL directement. Si la variable manque, le Worker refuse le relais avec **503**.

Les webhooks `commande` et `avis` restent publics **par conception**, pour permettre le parcours client depuis un QR code sans identifiants de personnel. Leurs URL sont visibles dans le navigateur ; cette ouverture ne donne pas accès aux huit webhooks administratifs. Elle reste une limite face aux abus : aucune protection anti-abus complète ni test de charge n’est démontré ici.

Cette sécurité de démonstration utilise un compte partagé, sans rôles individuels gérant/cuisine ni traçabilité individuelle. **Softr → Astro est un écart assumé par rapport à la fiche validée.** Softr gratuit est plafonné à **5 utilisateurs et une app**, sans panier ni prise de commande native (contraintes fournies pour cette mission). Le parcours client et les écrans gérant/cuisine sont en Astro statique, sur la même pile que le site, pour favoriser la performance mobile ; aucun gain chiffré comparatif n’est mesuré. La sécurité est assurée par le Worker Cloudflare, Airtable reste la base de données et n8n le moteur d’automatisation. Les preuves chiffrées et les impacts des deux substitutions sont détaillés dans [ECARTS-ASSUMES.md](ECARTS-ASSUMES.md). Voir [SECURITE-ACCES.md](SECURITE-ACCES.md) pour le détail du Worker et la portée des contrôles historiques.

Le mécanisme d’accès n’ajoute pas de collecte personnelle ; cela ne s’étend pas à tout le produit : l’avis peut transmettre un prénom, un commentaire et un e-mail facultatif. Aucun audit RGPD global n’est revendiqué.

### Secrets, stockage et limites métier

Le token de synchronisation Airtable est lu côté serveur par `sync.mjs` via `AIRTABLE_TOKEN` ; n8n utilise également ses propres accès serveur à Airtable. Il ne doit jamais être préfixé `PUBLIC_`, importé dans le front ou versionné. `AIRTABLE_BASE` vient aussi de l’environnement ; le synchroniseur ne charge pas `.env`. Ses erreurs n’affichent ni réponse distante brute ni en-têtes. La clé gérant et la clé dédiée au PDF restent également côté serveur, hors dépôt. Aucun secret n’est nécessaire pour un build sur fixtures.

Les mutations du front sont raccordées à n8n : commandes, avis, statuts, ruptures et produits importés peuvent être écrits dans Airtable. Les modifications de nom et de prix du formulaire d’édition courant restent locales (`updateProduct`) ; ne pas confondre ce formulaire avec l’import via `produits`. Les commandes sont partagées via `commandes`, ce qui permet le suivi cuisine depuis un autre appareil authentifié. Les pages publiques de confirmation et d’avis utilisent la commande mémorisée sur l’appareil, sans lire l’administration.

La clé `crypto.randomUUID()` est persistée avant création et réutilisée en revalidation ; modifier panier ou table la réinitialise. L’idempotence de commande est également traitée dans n8n. Les preuves de revalidation séquentielle ne démontrent pas une unicité transactionnelle sous requêtes simultanées. L’import de produits n’a pas de clé d’idempotence : après une réponse perdue, vérifier Airtable avant de réessayer pour éviter un doublon.

`localStorage` utilise `qramen-data`, `qramen-cart` et `qramen-theme` pour cache, panier et thème. Une panne réseau peut activer un repli explicitement affiché « Mode démo locale » ; une réponse HTTP d’échec reste une erreur affichée. Effacer ce stockage efface l’état local, pas les écritures Airtable déjà réalisées. Le secours mémoire ne garantit pas une conservation entre pages. Les données locales sont modifiables par l’utilisateur : elles ne sont pas une preuve d’écriture distante. Les prix et noms des lignes sont figés à la création. `setStatus` contrôle les valeurs autorisées ; seuls les boutons imposent leur progression. Les textes saisis sont échappés avant insertion HTML, sans prétendre à un audit de sécurité exhaustif.

Autres limites : visuels générés (illustrations, pas des photographies du restaurant), allergènes illustratifs, aucun paiement en ligne, pas de sauvegarde métier ni de test de charge documenté. La lecture n8n du catalogue signale l’incomplétude au-delà de sa première page de 100 produits ; le panel refuse alors de remplacer son catalogue. La carte publique compilée n’est pas actualisée automatiquement sur tous les appareils après une modification Airtable : synchronisation, build et déploiement restent nécessaires pour renouveler ce snapshot.

## Performances et choix techniques

Le choix porte sur un rendu statique Astro plutôt qu’un serveur dynamique ou un framework d’interface supplémentaire : le catalogue est préparé au build, le site se sert comme des fichiers et le prototype reste démontrable sans service distant. Compromis pour le catalogue public compilé : modification Airtable visible après synchronisation, build et déploiement ; le panel peut relire le catalogue à l’exécution ; interfaces métier alimentées en JavaScript, dépendance à n8n pour les opérations partagées et cache/repli local à distinguer des données distantes.

Les visuels WebP couvrent les 40 produits, en 900 px et en variantes 192/400/800, servies par `srcset`/`sizes` avec dimensions HTML, `loading="lazy"`, `decoding="async"` et alternatives. Ces attributs réservent la place et diffèrent le chargement ; ils ne démontrent pas à eux seuls un gain de vitesse. Le lazy loading de l’image d’accueil peut aussi retarder son affichage. Aucune mesure Lighthouse, Core Web Vitals, latence ou économie de bande passante n’est fournie : aucun score de performance revendiqué. Pas de police distante ni de framework CSS ajouté.

## Procédures reproductibles

Depuis la racine, Node ≥ 22.22.0 et npm, shell POSIX :

```sh
npm ci
npm run dev
# http://localhost:4321
```

Pour une carte Airtable actualisée, faire injecter les variables par le shell ou le gestionnaire de secrets, puis :

```sh
node outils/airtable/sync.mjs
npm run build
npm run preview
```

Ne pas poursuivre le déploiement d’une actualisation si la synchronisation échoue : le build pourrait publier le dernier snapshot. Pour une démonstration fixtures, aucun secret nécessaire ; en l’absence de snapshot, lancer simplement `npm run build`. La sortie se trouve dans `dist/`. Le snapshot public du catalogue est actuellement suivi dans Git pour la démonstration hors ligne (`.gitignore` n’exclut que son fichier temporaire). Une autre machine ou la CI peut donc compiler ce snapshot, sans garantie de fraîcheur ; les secrets ne doivent jamais y figurer.

Vérifier avec Chromium installé à `/usr/bin/chromium` et le build servi sur `http://localhost:4321` ; alternative de service documentée : `python3 -m http.server 4321 --directory dist`.

```sh
node outils/airtable/sync.test.mjs
node outils/parcours-client/verify.mjs
node outils/photos/verify.mjs
node outils/airtable/verify.mjs
npm run verify:browser
```

Les scripts navigateur écrivent leurs preuves dans les dossiers existants de `docs/`. Le contrôle Airtable exige un snapshot valide ; les autres vérifient les parcours, photos et styleguide. Pour le seul script design system, `CHROMIUM_PATH` et `BASE_URL` permettent de changer le navigateur et l’URL. Les audits automatisés complètent la lecture visuelle sans remplacer une recette humaine exhaustive.

### Vérifier la protection réellement livrée

`astro dev`, `astro preview` et un serveur statique n’exécutent pas le Worker : ils ne prouvent pas la protection de Pages. En local, les URL admin visent directement n8n et sont refusées sans la clé. Utiliser Wrangler avec des variables fournies hors dépôt pour une recette complète. Les tests du proxy avec secrets simulés sont documentés dans `SECURITE-ACCES.md`. Ne jamais ajouter la clé au navigateur pour contourner un refus.

Les preuves du raccordement et de l’import sont conservées dans [RAPPORT-FRONT-N8N.md](RAPPORT-FRONT-N8N.md) et [RAPPORT-IMPORT-AIRTABLE.md](RAPPORT-IMPORT-AIRTABLE.md). Elles décrivent leurs essais datés, pas une nouvelle exécution en production à 22 h.
