feat: package the application for Docker, with deployment docs
Multi-stage build on node:22-alpine, standalone output, non-root user, healthcheck on /api/health, and migrations applied by the entrypoint before the first request. A failed migration stops the container rather than serving an inconsistent database. Getting the Prisma CLI into the runtime image took three attempts and the reasoning is recorded in the Dockerfile. Copying it out of the build stage leaves its transitive dependencies behind; patching them in one at a time is a losing game. It now gets its own stage and its own tree, with the schema and prisma.config.ts beside it, and the entrypoint runs from there so every import resolves locally. The version is read from our own package.json so it cannot drift from the generated client. Two things had to change to build without a database, which a build container rightly does not have. prisma.config.ts no longer reads the URL through prisma's env() helper, which throws on a missing variable even for `generate`. And lib/db.ts creates the client on first use rather than on import: Next imports every route module while collecting page data, so a module that threw on import failed the build with an error naming whichever route was analysed first, which says nothing useful. The failure now lands on the first query, where it belongs. Verified by running the image against a real database: migrations applied, cron scheduled in Europe/Zurich, a device paired, and the panel image served as a genuine 1-bit 800x480 BMP — so satori, resvg and the vendored fonts all work on musl. The image hash came out identical to the one produced on the glibc host, which is the reproducibility the vendored fonts were for. The production overlay publishes through an existing Traefik, drops the host port, mounts the filesystem read-only, and adds a nightly dump kept for a fortnight. README and DEPLOY are in French and cover what actually bites: the panel receives nothing and only updates when it wakes; the issuer must match to the character; the captive portal URL takes no trailing slash; a rollback across a migration needs the dump, because Prisma does not undo one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
# Horaires ITA ITO
|
||||
|
||||
Panneau d'administration des horaires d'ouverture de la boutique **ITA ITO**, et
|
||||
serveur d'affichage pour un écran e-ink **TRMNL 7,5" (kit DIY Seeed)** posé en
|
||||
vitrine.
|
||||
|
||||
Changer un horaire depuis un téléphone, debout dans la boutique, en deux clics —
|
||||
et que l'écran suive tout seul, jours fériés genevois et message bilingue
|
||||
compris.
|
||||
|
||||
```
|
||||
Écran e-ink (ESP32, Wi-Fi boutique)
|
||||
│ GET /api/setup · GET /api/display · POST /api/log
|
||||
│ GET /api/device/image/<hash>.bmp
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Next.js 16 + PostgreSQL 16 │
|
||||
│ horaires.ita-ito.com │
|
||||
└──────────────────────────────────────────────┘
|
||||
▲ │
|
||||
│ OIDC ├─► api.loxi.ch (traduction FR→EN)
|
||||
Navigateur admin └─► openholidaysapi.org (jours fériés CH-GE)
|
||||
auth.loxi.ch
|
||||
```
|
||||
|
||||
## Ce qu'il faut savoir avant tout
|
||||
|
||||
**L'écran ne reçoit rien.** Il dort sur batterie, se réveille, demande quoi
|
||||
afficher, et se rendort. On ne peut pas lui pousser une mise à jour : le seul
|
||||
levier est la durée de son sommeil, réglable dans *Paramètres → Écran*. Une
|
||||
modification apparaît donc au réveil suivant, pas immédiatement. C'est la nature
|
||||
du BYOS, pas une limitation de cette application.
|
||||
|
||||
**Le nom du fichier image est le hash de son contenu.** Si rien n'a changé,
|
||||
l'écran reconnaît le nom, ne redessine pas, et économise sa batterie. C'est
|
||||
pourquoi le rendu doit rester reproductible à l'octet près — d'où les polices
|
||||
et le logo versionnés dans `public/`.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Docker et Docker Compose
|
||||
- Node.js ≥ 20.9 (seulement pour le développement hors conteneur)
|
||||
- Une application OIDC dans Authentik (voir plus bas)
|
||||
- Un écran TRMNL 7,5" dont le portail captif accepte un serveur personnalisé
|
||||
|
||||
## Installation locale
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
sed -i "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 33)|" .env
|
||||
# Renseignez ensuite POSTGRES_PASSWORD, AUTH_AUTHENTIK_*, TRANSLATION_API_KEY.
|
||||
|
||||
docker compose up -d db --wait # PostgreSQL seul
|
||||
npm install
|
||||
npx prisma migrate deploy
|
||||
npm run seed # horaires de démonstration, idempotent
|
||||
npm run dev # http://localhost:3010
|
||||
```
|
||||
|
||||
Le port 3010 n'est pas un caprice : 3000 est déjà pris par une autre pile sur la
|
||||
machine de développement.
|
||||
|
||||
Pour faire tourner l'application elle aussi en conteneur :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --wait
|
||||
```
|
||||
|
||||
### Commandes utiles
|
||||
|
||||
| Commande | Effet |
|
||||
|---|---|
|
||||
| `npm run dev` | serveur de développement sur 3010 |
|
||||
| `npm test` | toute la suite (unitaire + intégration) |
|
||||
| `npm run coverage` | idem, avec les seuils de couverture |
|
||||
| `npm run lint` / `npm run typecheck` | ESLint / TypeScript strict |
|
||||
| `npm run screen:preview` | rend l'écran dans `.preview/`, sans appareil |
|
||||
| `npm run brand` | régénère le logo depuis le CDN de la boutique |
|
||||
| `npm run holidays:sync` | synchronise les jours fériés maintenant |
|
||||
| `npm run translate:test "texte"` | vérifie le service de traduction |
|
||||
|
||||
Les tests d'intégration ont besoin d'une base à eux — `TEST_DATABASE_URL`. Ils
|
||||
la vident entièrement, donc elle ne doit jamais désigner une base qui contient
|
||||
quoi que ce soit d'utile. Sans cette variable, ils se désactivent plutôt que de
|
||||
détruire quelque chose.
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U horaires -d postgres -c "create database horaires_test owner horaires;"
|
||||
DATABASE_URL="$TEST_DATABASE_URL" npx prisma migrate deploy
|
||||
```
|
||||
|
||||
## Configuration d'Authentik
|
||||
|
||||
1. **Groupe** — créez `horaires-admins` et ajoutez-y les personnes qui peuvent
|
||||
modifier les horaires. **Ne liez pas ce groupe à l'application** : seuls les
|
||||
administrateurs pourraient alors se connecter, et le rôle lecture seule
|
||||
deviendrait inatteignable. Pour restreindre l'accès, créez un groupe plus
|
||||
large et liez celui-là.
|
||||
|
||||
2. **Provider** — *OAuth2/OpenID Provider* :
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Client type | Confidential |
|
||||
| Signing Key | n'importe quel certificat — **obligatoire** |
|
||||
| Redirect URIs (Strict) | `https://horaires.ita-ito.com/api/auth/callback/authentik`<br>`http://localhost:3010/api/auth/callback/authentik` |
|
||||
| Scopes | `openid`, `email`, `profile` (les valeurs par défaut) |
|
||||
| Subject mode | Based on the User's Email |
|
||||
| Include claims in id_token | activé |
|
||||
|
||||
Le claim `groups` arrive via le scope `profile` : aucun mapping à créer. Rien
|
||||
à activer pour le PKCE, Authentik annonce `S256` et Auth.js l'utilise seul.
|
||||
|
||||
3. **Application** — slug `horaires-ita-ito`, liée au provider. Le slug
|
||||
détermine l'issuer, qui doit correspondre **au caractère près, barre oblique
|
||||
finale comprise**.
|
||||
|
||||
4. **Vérification**, avant même de lancer l'application :
|
||||
|
||||
```bash
|
||||
curl -s https://auth.loxi.ch/application/o/horaires-ita-ito/.well-known/openid-configuration | jq .issuer
|
||||
```
|
||||
|
||||
La valeur doit être identique à `AUTH_AUTHENTIK_ISSUER`. C'est la première
|
||||
cause d'échec de connexion, et elle se diagnostique mal depuis l'application.
|
||||
|
||||
**Le rôle est fixé à la connexion.** Retirer quelqu'un du groupe ne coupe pas sa
|
||||
session en cours : l'effet arrive à la reconnexion, ou au bout de huit heures.
|
||||
Si un compte apparaît en lecture seule alors qu'il ne devrait pas, le tableau de
|
||||
bord affiche les groupes réellement reçus et celui qui était attendu.
|
||||
|
||||
## Appairage de l'écran
|
||||
|
||||
Aucun reflashage. Le firmware d'origine suffit.
|
||||
|
||||
1. Maintenez le bouton au dos de l'écran **5 secondes** : il ouvre un réseau
|
||||
Wi-Fi nommé `TRMNL`.
|
||||
2. Connectez-vous-y depuis un téléphone ; le portail s'ouvre tout seul.
|
||||
3. Donnez le Wi-Fi de la boutique, puis **Advanced → Custom Server → Yes**.
|
||||
4. Saisissez `https://horaires.ita-ito.com` — **sans barre oblique finale**.
|
||||
L'URL exacte est rappelée dans *Paramètres → Appareils*.
|
||||
5. Validez. L'écran s'enregistre seul au premier appel et affiche les horaires.
|
||||
|
||||
Il apparaît ensuite dans *Paramètres → Appareils* avec son identifiant, sa
|
||||
dernière visite, son firmware et sa batterie.
|
||||
|
||||
> **Si *Custom Server* n'apparaît pas** dans le portail, le firmware livré avec
|
||||
> le kit est une version qui ne l'expose pas. Il faut alors flasher le firmware
|
||||
> TRMNL officiel — ce qui sort du cadre de ce dépôt. Vérifiez ce point avant de
|
||||
> déployer quoi que ce soit.
|
||||
|
||||
## Mise en production
|
||||
|
||||
Voir **[DEPLOY.md](DEPLOY.md)** : première installation, mises à jour,
|
||||
sauvegardes, restauration et retour arrière.
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Symptôme | Cause la plus probable |
|
||||
|---|---|
|
||||
| Connexion refusée en boucle | `AUTH_AUTHENTIK_ISSUER` ne correspond pas exactement à l'issuer annoncé |
|
||||
| « Compte en lecture seule » | le compte n'est pas dans `AUTHENTIK_ADMIN_GROUP` ; le tableau de bord affiche les groupes reçus |
|
||||
| L'écran reste blanc | il n'a pas encore atteint le serveur : vérifiez l'URL du portail captif, sans barre oblique finale |
|
||||
| L'écran n'a pas suivi une modification | normal jusqu'à son prochain réveil ; le tableau de bord indique quand il est attendu |
|
||||
| « La traduction a échoué » | `TRANSLATION_API_KEY` absente ou révoquée — `npm run translate:test` le dit précisément |
|
||||
| Jours fériés absents | lancez `npm run holidays:sync` ; la page Fériés affiche la dernière synchro réussie |
|
||||
| Aperçu d'écran en erreur | le rendu a besoin de `public/fonts` et `public/brand` ; ils sont versionnés, vérifiez qu'ils sont présents |
|
||||
|
||||
## Licence
|
||||
|
||||
Propriétaire. Voir [LICENSE](LICENSE).
|
||||
Reference in New Issue
Block a user