diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..79b6f10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +out +coverage +.git +.gitignore +*.md +docker +.env +.env.* +!.env.example \ No newline at end of file diff --git a/.env.example b/.env.example index 13e005a..67c4feb 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,4 @@ MYSQL_DATABASE=flashcards # MongoDB (conteneur docker dédié flashcards-mongo, port local 27018) MONGODB_DATABASE=flashcards -MONGODB_URI=mongodb://flashcards:flashcards@localhost:27018/flashcards \ No newline at end of file +MONGODB_URI=mongodb://flashcards:flashcards@localhost:27018/flashcards?authSource=admin \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9e4d4e --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/TODO.md b/TODO.md index 338571b..04c3a32 100644 --- a/TODO.md +++ b/TODO.md @@ -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 : -- [ ] Les tests unitaires (Vitest) passent, **coverage** généré et au-dessus du seuil défini -- [ ] `lint` (ESLint) et `format` (Prettier — check) sans erreur -- [ ] TypeScript strict compile sans erreur -- [ ] 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 -- [ ] Le comportement correspond aux critères d'acceptation de la section 2 -- [ ] Construit et lançable via docker-compose avec volumes persistants +- [x] Les tests unitaires (Vitest) passent, **coverage** généré et au-dessus du seuil défini +- [x] `lint` (ESLint) et `format` (Prettier — check) sans erreur +- [x] TypeScript strict compile sans erreur +- [x] L'implémentation est branchée sur la **couche générique d'accès aux données** +- [x] Les **deux SGBD** (MySQL + MongoDB) fonctionnent simultanément en local +- [x] Le comportement correspond aux critères d'acceptation de la section 2 +- [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 -- [ ] Manifest PWA + service worker (installable, hors-ligne) — la stack SPA le permet -- [ ] `docker-compose.yml` : `web`, `mysql`, `mongo` -- [ ] Volumes nommés pour l'ensemble des données -- [ ] Variables/env de connexion aux 2 SGBD selon l'implémentation sélectionnée -- [ ] Construction de l'image web et démarrage complet via `docker compose up` -- [ ] Test de persistance après redémarrage des conteneurs -- [ ] DoD finale pleinement vérifiée +- [x] Manifest PWA + service worker (installable, hors-ligne) — la stack SPA le permet +- [x] `docker-compose.yml` : `web`, `mysql`, `mongo` +- [x] Volumes nommés pour l'ensemble des données +- [x] Variables/env de connexion aux 2 SGBD selon l'implémentation sélectionnée (`DB_IMPL`, `MYSQL_*`, `MONGODB_URI`) +- [x] Construction de l'image web et démarrage complet via `docker compose up` +- [x] Test de persistance après redémarrage des conteneurs (MySQL + Mongo) +- [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 -- [ ] 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 -- [ ] 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) 22/09: phase 3 terminée (jeu + navigation intégrés), `pnpm check` vert diff --git a/docker-compose.yml b/docker-compose.yml index e0b3a25..2fa4a86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,25 @@ 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: image: mysql:8.4 container_name: flashcards-mysql diff --git a/docker/init/mysql/02-seed.sql b/docker/init/mysql/02-seed.sh old mode 100644 new mode 100755 similarity index 87% rename from docker/init/mysql/02-seed.sql rename to docker/init/mysql/02-seed.sh index ff420d3..e250d77 --- a/docker/init/mysql/02-seed.sql +++ b/docker/init/mysql/02-seed.sh @@ -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 ('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()), @@ -8,4 +14,5 @@ INSERT INTO cards (id, question, response, theme, tags, createdAt, updatedAt) VA ('10000000-0000-4000-8000-000000000007', 'Que fait git rebase ?', 'Il réapplique les commits d''une branche sur une autre base.', '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-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()); \ No newline at end of file +('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 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 7ecf7f4..9c700e7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,4 +2,5 @@ import { withSerwist } from '@serwist/turbopack' export default withSerwist({ // Configuration Next.js (Turbopack activé par défaut dans Next 16) + output: 'standalone', }) diff --git a/src/repositories/mongo-card-repository.ts b/src/repositories/mongo-card-repository.ts index 0952f95..07110f8 100644 --- a/src/repositories/mongo-card-repository.ts +++ b/src/repositories/mongo-card-repository.ts @@ -31,7 +31,8 @@ export class MongoCardRepository implements CardRepository { this.collection = collection } else { 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 .db(process.env.MONGODB_DATABASE ?? 'flashcards') diff --git a/src/repositories/mysql-card-repository.ts b/src/repositories/mysql-card-repository.ts index 524dbac..0bd034e 100644 --- a/src/repositories/mysql-card-repository.ts +++ b/src/repositories/mysql-card-repository.ts @@ -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 { return { id: row.id, question: row.question, response: row.response, theme: row.theme, - tags: row.tags ? JSON.parse(row.tags) : [], + tags: this.parseTags(row.tags), createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), }