feat(deploy): image web Docker et docker-compose complet (P5)

- Dockerfile multi-étapes (node:22-alpine, pnpm, output standalone), .dockerignore
- docker-compose : service web (hôte 4000) + mysql + mongo avec volumes nommés
- Env de connexion selon DB_IMPL (MYSQL_*, MONGODB_URI) ; mongo ?authSource=admin
- Seed MySQL via 02-seed.sh (--default-character-set=utf8mb4) pour un UTF-8 propre
- fix(api): parseTags gère les colonnes JSON déjà parsées par mysql2
- TODO : phase 5 et DoD cochées
This commit is contained in:
devcodetools committed 2026-09-22 09:00:07 +02:00
1 parent 17ae195b39
commit f165168608
9 files changed
+111 -19

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
out
coverage
.git
.gitignore
*.md
docker
.env
.env.*
!.env.example
+1 -1
View File
@@ -13,4 +13,4 @@ MYSQL_DATABASE=flashcards
# MongoDB (conteneur docker dédié flashcards-mongo, port local 27018) # MongoDB (conteneur docker dédié flashcards-mongo, port local 27018)
MONGODB_DATABASE=flashcards MONGODB_DATABASE=flashcards
MONGODB_URI=mongodb://flashcards:flashcards@localhost:27018/flashcards MONGODB_URI=mongodb://flashcards:flashcards@localhost:27018/flashcards?authSource=admin
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+22 -16
View File
@@ -103,13 +103,13 @@ Les composants d'accès aux données doivent être **le plus générique possibl
Une feature est « faite » si et seulement si : Une feature est « faite » si et seulement si :
- [ ] Les tests unitaires (Vitest) passent, **coverage** généré et au-dessus du seuil défini - [x] Les tests unitaires (Vitest) passent, **coverage** généré et au-dessus du seuil défini
- [ ] `lint` (ESLint) et `format` (Prettier — check) sans erreur - [x] `lint` (ESLint) et `format` (Prettier — check) sans erreur
- [ ] TypeScript strict compile sans erreur - [x] TypeScript strict compile sans erreur
- [ ] L'implémentation est branchée sur la **couche générique d'accès aux données** - [x] L'implémentation est branchée sur la **couche générique d'accès aux données**
- [ ] Les **deux SGBD** (MySQL + MongoDB) fonctionnent simultanément en local - [x] Les **deux SGBD** (MySQL + MongoDB) fonctionnent simultanément en local
- [ ] Le comportement correspond aux critères d'acceptation de la section 2 - [x] Le comportement correspond aux critères d'acceptation de la section 2
- [ ] Construit et lançable via docker-compose avec volumes persistants - [x] Construit et lançable via docker-compose avec volumes persistants
--- ---
@@ -182,21 +182,27 @@ Traceabilité F-01 à F-10 (tests unitaires) :
### Phase 5 — PWA, déploiement ### Phase 5 — PWA, déploiement
- [ ] Manifest PWA + service worker (installable, hors-ligne) — la stack SPA le permet - [x] Manifest PWA + service worker (installable, hors-ligne) — la stack SPA le permet
- [ ] `docker-compose.yml` : `web`, `mysql`, `mongo` - [x] `docker-compose.yml` : `web`, `mysql`, `mongo`
- [ ] Volumes nommés pour l'ensemble des données - [x] Volumes nommés pour l'ensemble des données
- [ ] Variables/env de connexion aux 2 SGBD selon l'implémentation sélectionnée - [x] Variables/env de connexion aux 2 SGBD selon l'implémentation sélectionnée (`DB_IMPL`, `MYSQL_*`, `MONGODB_URI`)
- [ ] Construction de l'image web et démarrage complet via `docker compose up` - [x] Construction de l'image web et démarrage complet via `docker compose up`
- [ ] Test de persistance après redémarrage des conteneurs - [x] Test de persistance après redémarrage des conteneurs (MySQL + Mongo)
- [ ] DoD finale pleinement vérifiée - [x] DoD finale pleinement vérifiée
Notes Phase 5 :
- Image web multi-étapes (node:22-alpine, pnpm, `output: 'standalone'`), port hôte **4000** (3000/3001 occupés par l'hôte).
- Correction UTF-8 du seed MySQL : `02-seed.sh` avec `mysql --default-character-set=utf8mb4` (le `.sql` brut importait en double-encodage) ; lignes seed réparées en base via `CONVERT(BINARY(CONVERT(... USING latin1)) USING utf8mb4)`.
- Correction `parseTags()` dans le repository MySQL (`mysql2` auto-parse les colonnes JSON en tableaux).
- Mongo : URI avec `?authSource=admin` (l'utilisateur root est créé dans `admin`).
--- ---
## 7. Points à trancher / en attente de décision ## 7. Points à trancher / en attente de décision
- [ ] Couverture : seuil de coverage exact - [x] Couverture : seuil de coverage exact (>= 80 % imposé, effectif ~95 % stmts)
- [ ] Selon cas : `Theme` en table dédiée vs dérivation des `theme` distincts - [ ] Selon cas : `Theme` en table dédiée vs dérivation des `theme` distincts
- [ ] Choix par défaut de l'implémentation repository pour la prod (MySQL ou Mongo) - [x] Choix par défaut de l'implémentation repository pour la prod (MySQL par défaut via `DB_IMPL`)
- [ ] Authentification éventuelle (hors périmètre README, à confirmer si nécessaire) - [ ] Authentification éventuelle (hors périmètre README, à confirmer si nécessaire)
22/09: phase 3 terminée (jeu + navigation intégrés), `pnpm check` vert 22/09: phase 3 terminée (jeu + navigation intégrés), `pnpm check` vert
+21
View File
@@ -1,4 +1,25 @@
services: services:
web:
build: .
container_name: flashcards-web
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
mongo:
condition: service_healthy
environment:
DB_IMPL: ${DB_IMPL:-mysql}
MYSQL_HOST: mysql
MYSQL_PORT: '3306'
MYSQL_USER: flashcards
MYSQL_PASSWORD: flashcards
MYSQL_DATABASE: flashcards
MONGODB_URI: mongodb://flashcards:flashcards@mongo:27017/flashcards?authSource=admin
MONGODB_DATABASE: flashcards
ports:
- '4000:3000'
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
container_name: flashcards-mysql container_name: flashcards-mysql
+7
View File
@@ -1,3 +1,9 @@
#!/bin/bash
set -e
# Import du seed en forçant l'encodage UTF-8 (sinon les caractères accentués
# du fichier source sont interprétés en latin1 et stockés en double-encodage).
mysql --default-character-set=utf8mb4 "$MYSQL_DATABASE" <<'SQL'
INSERT INTO cards (id, question, response, theme, tags, createdAt, updatedAt) VALUES INSERT INTO cards (id, question, response, theme, tags, createdAt, updatedAt) VALUES
('10000000-0000-4000-8000-000000000001', 'Qu''est-ce qu''une closure en JavaScript ?', 'Une fonction qui conserve l''accès à la portée de sa définition.', 'javascript', JSON_ARRAY('scope', 'fonction'), NOW(), NOW()), ('10000000-0000-4000-8000-000000000001', 'Qu''est-ce qu''une closure en JavaScript ?', 'Une fonction qui conserve l''accès à la portée de sa définition.', 'javascript', JSON_ARRAY('scope', 'fonction'), NOW(), NOW()),
('10000000-0000-4000-8000-000000000002', 'Que retourne typeof null ?', 'La chaîne "object", héritage historique du langage.', 'javascript', JSON_ARRAY('types'), NOW(), NOW()), ('10000000-0000-4000-8000-000000000002', 'Que retourne typeof null ?', 'La chaîne "object", héritage historique du langage.', 'javascript', JSON_ARRAY('types'), NOW(), NOW()),
@@ -9,3 +15,4 @@ INSERT INTO cards (id, question, response, theme, tags, createdAt, updatedAt) VA
('10000000-0000-4000-8000-000000000008', 'Différence entre merge et rebase ?', 'merge crée un commit de fusion, rebase réécrit l''historique de façon linéaire.', 'git', JSON_ARRAY('branches'), NOW(), NOW()), ('10000000-0000-4000-8000-000000000008', 'Différence entre merge et rebase ?', 'merge crée un commit de fusion, rebase réécrit l''historique de façon linéaire.', 'git', JSON_ARRAY('branches'), NOW(), NOW()),
('10000000-0000-4000-8000-000000000009', 'Qu''est-ce qu''un volume Docker ?', 'Un stockage persistant indépendant du cycle de vie du conteneur.', 'docker', JSON_ARRAY('stockage'), NOW(), NOW()), ('10000000-0000-4000-8000-000000000009', 'Qu''est-ce qu''un volume Docker ?', 'Un stockage persistant indépendant du cycle de vie du conteneur.', 'docker', JSON_ARRAY('stockage'), NOW(), NOW()),
('10000000-0000-4000-8000-000000000010', 'Différence entre RUN et CMD dans un Dockerfile ?', 'RUN s''exécute au build, CMD définit la commande lancée au démarrage du conteneur.', 'docker', JSON_ARRAY('dockerfile'), NOW(), NOW()); ('10000000-0000-4000-8000-000000000010', 'Différence entre RUN et CMD dans un Dockerfile ?', 'RUN s''exécute au build, CMD définit la commande lancée au démarrage du conteneur.', 'docker', JSON_ARRAY('dockerfile'), NOW(), NOW());
SQL
+1
View File
@@ -2,4 +2,5 @@ import { withSerwist } from '@serwist/turbopack'
export default withSerwist({ export default withSerwist({
// Configuration Next.js (Turbopack activé par défaut dans Next 16) // Configuration Next.js (Turbopack activé par défaut dans Next 16)
output: 'standalone',
}) })
+2 -1
View File
@@ -31,7 +31,8 @@ export class MongoCardRepository implements CardRepository {
this.collection = collection this.collection = collection
} else { } else {
const client = new MongoClient( const client = new MongoClient(
process.env.MONGODB_URI ?? 'mongodb://flashcards:flashcards@localhost:27018/flashcards', process.env.MONGODB_URI ??
'mongodb://flashcards:flashcards@localhost:27018/flashcards?authSource=admin',
) )
this.collection = client this.collection = client
.db(process.env.MONGODB_DATABASE ?? 'flashcards') .db(process.env.MONGODB_DATABASE ?? 'flashcards')
+16 -1
View File
@@ -29,13 +29,28 @@ export class MysqlCardRepository implements CardRepository {
}) })
} }
private parseTags(value: string | unknown): string[] {
if (Array.isArray(value)) {
return value as string[]
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
private mapRow(row: CardRow): Card { private mapRow(row: CardRow): Card {
return { return {
id: row.id, id: row.id,
question: row.question, question: row.question,
response: row.response, response: row.response,
theme: row.theme, theme: row.theme,
tags: row.tags ? JSON.parse(row.tags) : [], tags: this.parseTags(row.tags),
createdAt: new Date(row.createdAt), createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt), updatedAt: new Date(row.updatedAt),
} }