diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..31cfae7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +coverage +.env diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..f320767 --- /dev/null +++ b/.env.template @@ -0,0 +1,8 @@ +#Local mode, set to true to access admin pages with any users +LOCAL=true + +POSTGRES_HOST = db +POSTGRES_PORT = 5432 +POSTGRES_PASS = astrongpass +POSTGRES_USER = postgres +POSTGRES_DB = polympr diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7e44244..6c8349d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,9 +6,26 @@ on: - main jobs: + check-code: + name: "Check Deno code" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + - name: Check linting + run: deno lint + deploy: name: "Build Docker image" runs-on: ubuntu-latest + needs: check-code steps: - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 6cbfc6d..8bb5888 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -4,6 +4,10 @@ on: pull_request: branches: - main + - develop + push: + branches: + - develop permissions: contents: read diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..259baf7 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,83 @@ +name: "Tests" + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - develop + +jobs: + unit: + name: "Unit tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Install dependencies + run: deno install + + - name: Run unit tests + run: deno task test:unit + + integration: + name: "Integration tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Start postgres + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null + PG_VER=$(ls /etc/postgresql/) + sudo sed -i "s/^#*listen_addresses\s*=.*/listen_addresses = '127.0.0.1'/" /etc/postgresql/$PG_VER/main/postgresql.conf + echo "host all all 127.0.0.1/32 md5" | sudo tee -a /etc/postgresql/$PG_VER/main/pg_hba.conf + sudo pg_ctlcluster $PG_VER main restart + until sudo -u postgres pg_isready -h 127.0.0.1; do sleep 1; done + sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" + sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" + sudo -u postgres psql -d polympr_test -c "GRANT ALL ON SCHEMA public TO test;" + + - name: Apply migrations + run: | + sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + + - name: Install dependencies + run: npm install --ignore-scripts && deno install + + - name: Run integration tests + env: + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task test:integration + + - name: Run e2e tests + env: + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task test:e2e diff --git a/Dockerfile b/Dockerfile index 1a335a7..61f7fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ FROM denoland/deno:alpine +RUN apk add --no-cache nodejs npm + WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev + COPY . . RUN deno cache main.ts --allow-import RUN deno task build diff --git a/bugs.md b/bugs.md new file mode 100644 index 0000000..46cf4b7 --- /dev/null +++ b/bugs.md @@ -0,0 +1,158 @@ +# Bug Report — PolyMPR + +> Généré le 2026-04-23 + +--- + +## 🔴 Critique + +### #1 — Schema mismatch : module mobility entièrement cassé + +**Fichier** : `routes/(apps)/mobility/api/insert_mobility.ts` + +Références à des colonnes inexistantes dans le schéma Drizzle : + +| Utilisé dans le code | Colonne réelle | +| ---------------------- | ------------------ | +| `students.userId` | `students.numEtud` | +| `students.firstName` | `students.nom` | +| `students.lastName` | `students.prenom` | +| `students.promotionId` | `students.idPromo` | +| `promotions.endyear` | `promotions.annee` | +| `promotions.current` | _(n'existe pas)_ | + +Le module crashe à l'exécution. À corriger en alignant les noms de colonnes avec +le schéma. + +--- + +### #2 — Auth manquante sur de nombreux endpoints + +Les endpoints suivants n'ont aucune vérification `eduPersonPrimaryAffiliation` : + +- `routes/(apps)/notes/api/notes.ts` (GET, POST) +- `routes/(apps)/notes/api/ue-modules.ts` (GET, POST) +- `routes/(apps)/notes/api/ues.ts` (GET, POST) +- `routes/(apps)/notes/api/ues/[idUE].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/users.ts` (GET, POST) +- `routes/(apps)/admin/api/users/[id].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/modules/[idModule].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/roles.ts` (GET, POST) +- `routes/(apps)/admin/api/roles/[idRole].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/permissions.ts` (GET) +- `routes/(apps)/mobility/api/insert_mobility.ts` + +Tous ces endpoints exposent des données sensibles sans vérifier les permissions. + +--- + +## 🟠 Haut + +### #3 — Bug Drizzle ORM : `.where()` avec plusieurs `eq()` sans `and()` + +**Fichier** : `routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts` — lignes +34, 72, 100 + +`.where()` n'accepte qu'un seul argument. Passer plusieurs `eq()` séparés par +des virgules ne génère pas le SQL attendu (seule la première condition est prise +en compte). + +```ts +// ❌ Incorrect +.where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + +// ✅ Correct +.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) +``` + +--- + +### #4 — Bug Drizzle ORM : `.where()` à 3 conditions sans `and()` + +**Fichier** : +`routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts` — handler +GET (~ligne 41) + +Même problème que #3, mais avec 3 conditions. Les handlers PUT et DELETE ont +déjà `and()`, seul le GET est affecté. + +```ts +// ❌ Incorrect +.where( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), +) + +// ✅ Correct +.where( + and( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), + ), +) +``` + +--- + +## 🟡 Moyen + +### #5 — `and()` passé avec des valeurs `undefined` + +**Fichier** : `routes/(apps)/notes/api/ue-modules.ts` + +```ts +and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, +); +``` + +Drizzle tolère les `undefined` dans `and()` dans certaines versions, mais ce +n'est pas garanti. Mieux vaut construire les conditions dynamiquement avant de +les passer. + +--- + +### #6 — Validation `!numEtud` rejette faussement `0` + +**Fichier** : `routes/(apps)/notes/api/notes.ts` — handler POST + +```ts +// ❌ Rejette numEtud = 0 +if (note === undefined || !numEtud || !idModule) + +// ✅ Correct +if (note === undefined || numEtud === undefined || numEtud === null || !idModule) +``` + +--- + +### #7 — `Number(idRole)` sans vérification `isNaN` + +**Fichier** : `routes/(apps)/admin/api/users.ts` + +Si `idRole` est une chaîne non numérique, `Number()` retourne `NaN` ce qui +provoque une erreur SQL. + +```ts +// ❌ Pas de vérification +const rows = idRole + ? await db.select().from(users).where(eq(users.idRole, Number(idRole))) + : await db.select().from(users); + +// ✅ Valider avant usage +const role = Number(idRole); +if (isNaN(role)) return new Response(..., { status: 400 }); +``` + +--- + +### #8 — Réponses d'erreur en texte brut au lieu de JSON + +**Fichier** : `routes/(apps)/notes/api/notes.ts` + +Certaines réponses d'erreur retournent une string sans +`content-type: application/json`, incohérent avec le reste de l'API qui retourne +`{ error: "..." }`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..983b912 --- /dev/null +++ b/bun.lock @@ -0,0 +1,233 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "dotenv": "^17.4.0", + "drizzle-orm": "^0.45.2", + "pg": "^8.20.0", + }, + "devDependencies": { + "@types/pg": "^8.20.0", + "drizzle-kit": "^0.31.10", + "tsx": "^4.21.0", + }, + }, + }, + "packages": { + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "dotenv": ["dotenv@17.4.0", "", {}, "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ=="], + + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + } +} diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..a20b1e8 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,41 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASS} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-polympr} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: registry.docker.polytech.djalim.fr/polympr:latest + working_dir: /app + restart: "no" + command: ["node", "node_modules/.bin/drizzle-kit", "migrate"] + env_file: .env + depends_on: + db: + condition: service_healthy + + app: + image: registry.docker.polytech.djalim.fr/polympr:latest + restart: unless-stopped + ports: + - "4430:443" + env_file: .env + volumes: + - contracts:/app/uploads/contracts + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + db_data: + contracts: diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..89a1142 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,56 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: testpass + POSTGRES_USER: postgres + POSTGRES_DB: polympr_test + volumes: + - db_data_test:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: node:alpine + working_dir: /app + restart: "no" + volumes: + - .:/app + command: node_modules/.bin/drizzle-kit migrate + environment: + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + depends_on: + db: + condition: service_healthy + + app: + image: denoland/deno:alpine + working_dir: /app + volumes: + - .:/app + - deno_cache:/deno-dir + command: run -A --unstable-ffi main.ts + ports: + - "4430:443" + environment: + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + LOCAL: "true" + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + db_data_test: + deno_cache: diff --git a/compose.yml b/compose.yml deleted file mode 100644 index 530640a..0000000 --- a/compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -services: - app: - container_name: deno_fresh_app - build: . - ports: - - "80:80" - - "443:443" - volumes: - - .:/app - command: deno run -A main.ts diff --git a/databases/db.ts b/databases/db.ts new file mode 100644 index 0000000..05326f9 --- /dev/null +++ b/databases/db.ts @@ -0,0 +1,14 @@ +import { drizzle } from "npm:drizzle-orm@0.45.2/node-postgres"; +import pg from "npm:pg@8.20.0"; + +const { Pool } = pg; + +const pool = new Pool({ + host: Deno.env.get("POSTGRES_HOST"), + port: Number(Deno.env.get("POSTGRES_PORT") ?? 5432), + user: Deno.env.get("POSTGRES_USER"), + password: Deno.env.get("POSTGRES_PASS"), + database: Deno.env.get("POSTGRES_DB"), +}); + +export const db = drizzle(pool); diff --git a/databases/docker-init.sh b/databases/docker-init.sh new file mode 100755 index 0000000..0db1cf6 --- /dev/null +++ b/databases/docker-init.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Applied by postgres on first container startup via /docker-entrypoint-initdb.d. +# drizzle-kit migration files use "--> statement-breakpoint" markers which are +# not valid SQL — strip them before applying. +set -e +for f in /migrations/*.sql; do + echo "Applying $f..." + sed '/^-->/d' "$f" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" +done +echo "All migrations applied." diff --git a/databases/migrations/0000_square_jetstream.sql b/databases/migrations/0000_square_jetstream.sql new file mode 100644 index 0000000..770b4c5 --- /dev/null +++ b/databases/migrations/0000_square_jetstream.sql @@ -0,0 +1,100 @@ +CREATE TABLE "ajustements" ( + "numEtud" integer NOT NULL, + "idUE" integer NOT NULL, + "valeur" double precision NOT NULL, + CONSTRAINT "ajustements_numEtud_idUE_pk" PRIMARY KEY("numEtud","idUE") +); +--> statement-breakpoint +CREATE TABLE "enseignements" ( + "idProf" text NOT NULL, + "idModule" text NOT NULL, + "idPromo" text NOT NULL, + CONSTRAINT "enseignements_idProf_idModule_idPromo_pk" PRIMARY KEY("idProf","idModule","idPromo") +); +--> statement-breakpoint +CREATE TABLE "mobility" ( + "id" serial PRIMARY KEY NOT NULL, + "studentId" integer, + "startDate" date, + "endDate" date, + "weeksCount" integer, + "destinationCountry" text, + "destinationName" text, + "mobilityStatus" text DEFAULT 'N/A' +); +--> statement-breakpoint +CREATE TABLE "modules" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notes" ( + "numEtud" integer NOT NULL, + "idModule" text NOT NULL, + "note" double precision NOT NULL, + CONSTRAINT "notes_numEtud_idModule_pk" PRIMARY KEY("numEtud","idModule") +); +--> statement-breakpoint +CREATE TABLE "permissions" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "promotions" ( + "idPromo" text PRIMARY KEY NOT NULL, + "annee" text +); +--> statement-breakpoint +CREATE TABLE "role_permissions" ( + "idRole" integer NOT NULL, + "idPermission" text NOT NULL, + CONSTRAINT "role_permissions_idRole_idPermission_pk" PRIMARY KEY("idRole","idPermission") +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "students" ( + "numEtud" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL, + "prenom" text NOT NULL, + "idPromo" text +); +--> statement-breakpoint +CREATE TABLE "ue_modules" ( + "idModule" text NOT NULL, + "idUE" integer NOT NULL, + "idPromo" text NOT NULL, + "coeff" double precision NOT NULL, + CONSTRAINT "ue_modules_idModule_idUE_idPromo_pk" PRIMARY KEY("idModule","idUE","idPromo") +); +--> statement-breakpoint +CREATE TABLE "ues" ( + "id" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL, + "prenom" text NOT NULL, + "idRole" integer +); +--> statement-breakpoint +ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idProf_users_id_fk" FOREIGN KEY ("idProf") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mobility" ADD CONSTRAINT "mobility_studentId_students_numEtud_fk" FOREIGN KEY ("studentId") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idPermission_permissions_id_fk" FOREIGN KEY ("idPermission") REFERENCES "public"."permissions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "students" ADD CONSTRAINT "students_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/databases/migrations/0001_seed_permissions.sql b/databases/migrations/0001_seed_permissions.sql new file mode 100644 index 0000000..922f6fa --- /dev/null +++ b/databases/migrations/0001_seed_permissions.sql @@ -0,0 +1,11 @@ +--> statement-breakpoint +INSERT INTO "permissions" ("id", "nom") VALUES + ('note_read', 'Consulter les notes des étudiants'), + ('note_write', 'Saisir et modifier les notes'), + ('student_read', 'Consulter la liste des étudiants'), + ('student_write','Gérer les étudiants (ajout, modification, suppression)'), + ('module_read', 'Consulter les modules et enseignements'), + ('module_write', 'Gérer les modules et enseignements'), + ('user_read', 'Consulter les utilisateurs et leurs rôles'), + ('user_write', 'Gérer les utilisateurs et leurs rôles'), + ('role_write', 'Gérer les rôles et leurs permissions'); diff --git a/databases/migrations/0002_update_permission_names.sql b/databases/migrations/0002_update_permission_names.sql new file mode 100644 index 0000000..d598c10 --- /dev/null +++ b/databases/migrations/0002_update_permission_names.sql @@ -0,0 +1,14 @@ +-- Update permission names to French +-- This migration inserts or updates the permission labels used by the API. +--> statement-breakpoint +INSERT INTO "permissions" ("id", "nom") VALUES + ('note_read', 'Consulter les notes des étudiants'), + ('note_write', 'Saisir et modifier les notes'), + ('student_read', 'Consulter la liste des étudiants'), + ('student_write','Gérer les étudiants (ajout, modification, suppression)'), + ('module_read', 'Consulter les modules et enseignements'), + ('module_write', 'Gérer les modules et enseignements'), + ('user_read', 'Consulter les utilisateurs et leurs rôles'), + ('user_write', 'Gérer les utilisateurs et leurs rôles'), + ('role_write', 'Gérer les rôles et leurs permissions') +ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom"; diff --git a/databases/migrations/0003_add_session2_and_malus.sql b/databases/migrations/0003_add_session2_and_malus.sql new file mode 100644 index 0000000..d3a950b --- /dev/null +++ b/databases/migrations/0003_add_session2_and_malus.sql @@ -0,0 +1,3 @@ +ALTER TABLE "notes" ADD COLUMN "noteSession2" double precision; +--> statement-breakpoint +ALTER TABLE "ajustements" ADD COLUMN "malus" integer NOT NULL DEFAULT 0; diff --git a/databases/migrations/0004_add_stages_and_mobilites.sql b/databases/migrations/0004_add_stages_and_mobilites.sql new file mode 100644 index 0000000..a1f8a5d --- /dev/null +++ b/databases/migrations/0004_add_stages_and_mobilites.sql @@ -0,0 +1,28 @@ +DROP TABLE IF EXISTS "mobility"; +--> statement-breakpoint +CREATE TYPE "mobility_status" AS ENUM ('contracts_received', 'under_revision', 'done', 'validated', 'canceled'); +--> statement-breakpoint +CREATE TABLE "stages" ( + "idStage" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "nomEntreprise" text NOT NULL, + "mission" text +); +--> statement-breakpoint +CREATE TABLE "mobilites" ( + "idMob" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "contratMob" text, + "ecole" text, + "pays" text, + "status" "mobility_status" NOT NULL DEFAULT 'contracts_received', + "idStage" integer +); +--> statement-breakpoint +ALTER TABLE "stages" ADD CONSTRAINT "stages_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_idStage_stages_idStage_fk" FOREIGN KEY ("idStage") REFERENCES "public"."stages"("idStage") ON DELETE no action ON UPDATE no action; diff --git a/databases/migrations/meta/0000_snapshot.json b/databases/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..819cf78 --- /dev/null +++ b/databases/migrations/meta/0000_snapshot.json @@ -0,0 +1,680 @@ +{ + "id": "bd317b68-1c46-4e83-b4d3-a14f68751afb", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ajustements": { + "name": "ajustements", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idUE": { + "name": "idUE", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "valeur": { + "name": "valeur", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ajustements_numEtud_students_numEtud_fk": { + "name": "ajustements_numEtud_students_numEtud_fk", + "tableFrom": "ajustements", + "tableTo": "students", + "columnsFrom": [ + "numEtud" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ajustements_idUE_ues_id_fk": { + "name": "ajustements_idUE_ues_id_fk", + "tableFrom": "ajustements", + "tableTo": "ues", + "columnsFrom": [ + "idUE" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ajustements_numEtud_idUE_pk": { + "name": "ajustements_numEtud_idUE_pk", + "columns": [ + "numEtud", + "idUE" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enseignements": { + "name": "enseignements", + "schema": "", + "columns": { + "idProf": { + "name": "idProf", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "enseignements_idProf_users_id_fk": { + "name": "enseignements_idProf_users_id_fk", + "tableFrom": "enseignements", + "tableTo": "users", + "columnsFrom": [ + "idProf" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "enseignements_idModule_modules_id_fk": { + "name": "enseignements_idModule_modules_id_fk", + "tableFrom": "enseignements", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "enseignements_idPromo_promotions_idPromo_fk": { + "name": "enseignements_idPromo_promotions_idPromo_fk", + "tableFrom": "enseignements", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "enseignements_idProf_idModule_idPromo_pk": { + "name": "enseignements_idProf_idModule_idPromo_pk", + "columns": [ + "idProf", + "idModule", + "idPromo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobility": { + "name": "mobility", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "studentId": { + "name": "studentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "startDate": { + "name": "startDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endDate": { + "name": "endDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "weeksCount": { + "name": "weeksCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "destinationCountry": { + "name": "destinationCountry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destinationName": { + "name": "destinationName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobilityStatus": { + "name": "mobilityStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'N/A'" + } + }, + "indexes": {}, + "foreignKeys": { + "mobility_studentId_students_numEtud_fk": { + "name": "mobility_studentId_students_numEtud_fk", + "tableFrom": "mobility", + "tableTo": "students", + "columnsFrom": [ + "studentId" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.modules": { + "name": "modules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notes_numEtud_students_numEtud_fk": { + "name": "notes_numEtud_students_numEtud_fk", + "tableFrom": "notes", + "tableTo": "students", + "columnsFrom": [ + "numEtud" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notes_idModule_modules_id_fk": { + "name": "notes_idModule_modules_id_fk", + "tableFrom": "notes", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notes_numEtud_idModule_pk": { + "name": "notes_numEtud_idModule_pk", + "columns": [ + "numEtud", + "idModule" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "annee": { + "name": "annee", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "idRole": { + "name": "idRole", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idPermission": { + "name": "idPermission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_idRole_roles_id_fk": { + "name": "role_permissions_idRole_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "idRole" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "role_permissions_idPermission_permissions_id_fk": { + "name": "role_permissions_idPermission_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "idPermission" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_idRole_idPermission_pk": { + "name": "role_permissions_idRole_idPermission_pk", + "columns": [ + "idRole", + "idPermission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.students": { + "name": "students", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prenom": { + "name": "prenom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "students_idPromo_promotions_idPromo_fk": { + "name": "students_idPromo_promotions_idPromo_fk", + "tableFrom": "students", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ue_modules": { + "name": "ue_modules", + "schema": "", + "columns": { + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idUE": { + "name": "idUE", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coeff": { + "name": "coeff", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ue_modules_idModule_modules_id_fk": { + "name": "ue_modules_idModule_modules_id_fk", + "tableFrom": "ue_modules", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ue_modules_idUE_ues_id_fk": { + "name": "ue_modules_idUE_ues_id_fk", + "tableFrom": "ue_modules", + "tableTo": "ues", + "columnsFrom": [ + "idUE" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ue_modules_idPromo_promotions_idPromo_fk": { + "name": "ue_modules_idPromo_promotions_idPromo_fk", + "tableFrom": "ue_modules", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ue_modules_idModule_idUE_idPromo_pk": { + "name": "ue_modules_idModule_idUE_idPromo_pk", + "columns": [ + "idModule", + "idUE", + "idPromo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ues": { + "name": "ues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prenom": { + "name": "prenom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idRole": { + "name": "idRole", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_idRole_roles_id_fk": { + "name": "users_idRole_roles_id_fk", + "tableFrom": "users", + "tableTo": "roles", + "columnsFrom": [ + "idRole" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json new file mode 100644 index 0000000..3cb93bd --- /dev/null +++ b/databases/migrations/meta/_journal.json @@ -0,0 +1,41 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777155028708, + "tag": "0000_square_jetstream", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777155028709, + "tag": "0001_seed_permissions", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1777155028710, + "tag": "0002_update_permission_names", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777155028711, + "tag": "0003_add_session2_and_malus", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1777155028712, + "tag": "0004_add_stages_and_mobilites", + "breakpoints": true + } + ] +} diff --git a/databases/schema.kit.ts b/databases/schema.kit.ts new file mode 100644 index 0000000..ceb3cfa --- /dev/null +++ b/databases/schema.kit.ts @@ -0,0 +1,99 @@ +import { + date, + doublePrecision, + integer, + pgTable, + primaryKey, + serial, + text, +} from "drizzle-orm/pg-core"; + +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const permissions = pgTable("permissions", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const rolePermissions = pgTable("role_permissions", { + idRole: integer("idRole").notNull().references(() => roles.id), + idPermission: text("idPermission").notNull().references(() => permissions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idRole, t.idPermission] }), +})); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idRole: integer("idRole").references(() => roles.id), +}); + +export const promotions = pgTable("promotions", { + id: text("idPromo").primaryKey(), + annee: text("annee"), +}); + +export const students = pgTable("students", { + numEtud: serial("numEtud").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idPromo: text("idPromo").references(() => promotions.id), +}); + +export const modules = pgTable("modules", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const enseignements = pgTable("enseignements", { + idProf: text("idProf").notNull().references(() => users.id), + idModule: text("idModule").notNull().references(() => modules.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }), +})); + +export const ues = pgTable("ues", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const ueModules = pgTable("ue_modules", { + idModule: text("idModule").notNull().references(() => modules.id), + idUE: integer("idUE").notNull().references(() => ues.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), + coeff: doublePrecision("coeff").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }), +})); + +export const notes = pgTable("notes", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idModule: text("idModule").notNull().references(() => modules.id), + note: doublePrecision("note").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idModule] }), +})); + +export const ajustements = pgTable("ajustements", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idUE: integer("idUE").notNull().references(() => ues.id), + valeur: doublePrecision("valeur").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idUE] }), +})); + +export const mobility = pgTable("mobility", { + id: serial("id").primaryKey(), + studentId: integer("studentId").references(() => students.numEtud), + startDate: date("startDate"), + endDate: date("endDate"), + weeksCount: integer("weeksCount"), + destinationCountry: text("destinationCountry"), + destinationName: text("destinationName"), + mobilityStatus: text("mobilityStatus").default("N/A"), +}); diff --git a/databases/schema.ts b/databases/schema.ts new file mode 100644 index 0000000..eadbb3a --- /dev/null +++ b/databases/schema.ts @@ -0,0 +1,117 @@ +import { + doublePrecision, + integer, + pgEnum, + pgTable, + primaryKey, + serial, + text, +} from "npm:drizzle-orm@0.45.2/pg-core"; + +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const permissions = pgTable("permissions", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const rolePermissions = pgTable("role_permissions", { + idRole: integer("idRole").notNull().references(() => roles.id), + idPermission: text("idPermission").notNull().references(() => permissions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idRole, t.idPermission] }), +})); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idRole: integer("idRole").references(() => roles.id), +}); + +export const promotions = pgTable("promotions", { + id: text("idPromo").primaryKey(), + annee: text("annee"), +}); + +export const students = pgTable("students", { + numEtud: serial("numEtud").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idPromo: text("idPromo").references(() => promotions.id), +}); + +export const modules = pgTable("modules", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const enseignements = pgTable("enseignements", { + idProf: text("idProf").notNull().references(() => users.id), + idModule: text("idModule").notNull().references(() => modules.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }), +})); + +export const ues = pgTable("ues", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const ueModules = pgTable("ue_modules", { + idModule: text("idModule").notNull().references(() => modules.id), + idUE: integer("idUE").notNull().references(() => ues.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), + coeff: doublePrecision("coeff").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }), +})); + +export const notes = pgTable("notes", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idModule: text("idModule").notNull().references(() => modules.id), + note: doublePrecision("note").notNull(), + noteSession2: doublePrecision("noteSession2"), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idModule] }), +})); + +export const ajustements = pgTable("ajustements", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idUE: integer("idUE").notNull().references(() => ues.id), + valeur: doublePrecision("valeur").notNull(), + malus: integer("malus").notNull().default(0), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idUE] }), +})); + +export const stages = pgTable("stages", { + id: serial("idStage").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + nomEntreprise: text("nomEntreprise").notNull(), + mission: text("mission"), +}); + +export const mobilityStatusEnum = pgEnum("mobility_status", [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +]); + +export const mobilites = pgTable("mobilites", { + id: serial("idMob").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + contratMob: text("contratMob"), + ecole: text("ecole"), + pays: text("pays"), + status: mobilityStatusEnum("status").notNull().default("contracts_received"), + idStage: integer("idStage").references(() => stages.id), +}); diff --git a/defaults/ImportResultPopup.tsx b/defaults/ImportResultPopup.tsx new file mode 100644 index 0000000..075db00 --- /dev/null +++ b/defaults/ImportResultPopup.tsx @@ -0,0 +1,102 @@ +import { useState } from "preact/hooks"; + +export type ImportResult = { + added: number; + modified: number; + ignored: number; + errors: number; + details: ImportDetail[]; +}; + +export type ImportDetail = { + type: "change" | "error"; + message: string; +}; + +type Props = { + result: ImportResult; + onClose: () => void; +}; + +export default function ImportResultPopup({ result, onClose }: Props) { + const [showDetails, setShowDetails] = useState(false); + const hasErrors = result.errors > 0; + const changes = result.details.filter((d) => d.type === "change"); + const errors = result.details.filter((d) => d.type === "error"); + + return ( +
+
e.stopPropagation()}> +
+

Resultats de l'import

+ + {hasErrors ? "Erreur" : "Succes"} + +
+ +
+
+ Ajoutes + + {result.added} note{result.added !== 1 ? "s" : ""} + +
+
+ Modifies + + {result.modified} note{result.modified !== 1 ? "s" : ""} + +
+
+ Ignores + + {result.ignored} note{result.ignored !== 1 ? "s" : ""} + +
+
+ Erreurs + + {result.errors} note{result.errors !== 1 ? "s" : ""} + +
+
+ +
+ {result.details.length > 0 && ( + + )} + +
+ + {showDetails && result.details.length > 0 && ( +
+ {changes.length > 0 && + changes.map((d, i) => ( +

{d.message}

+ ))} + {errors.length > 0 && + errors.map((d, i) => ( +

{d.message}

+ ))} +
+ )} +
+
+ ); +} diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index f385846..951201a 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -19,6 +19,8 @@ export interface AppProperties { icon: string; pages: Record; adminOnly: string[]; + studentOnly?: string[]; + employeeOnly?: boolean; hint: string; } diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts new file mode 100644 index 0000000..d1110fe --- /dev/null +++ b/defaults/makeSlug.ts @@ -0,0 +1,62 @@ +import { FreshContext } from "$fresh/server.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +import { ComponentChildren } from "preact"; + +/** + * Generates a catch-all [slug] route that dynamically loads partials. + * This enables direct URL navigation to sub-pages (e.g. /admin/modules). + * @param basePath The base path of the module, should be `import.meta.dirname!`. + * @returns A route handler that loads the partial matching the slug. + */ +export default function makeSlug(basePath: string): Route { + return async function SlugRoute( + request: Request, + context: FreshContext, + ): Promise { + const slug = context.params.slug; + + // Try partials/.tsx, then partials/(admin)/.tsx + let page: Route | undefined; + try { + page = (await import(`${basePath}/partials/${slug}.tsx`)).Page; + } catch { + try { + page = (await import(`${basePath}/partials/(admin)/${slug}.tsx`)).Page; + } catch { + // No partial found for this slug + } + } + + // For multi-segment slugs (e.g. "overview/12345"), try + // partials//[param].tsx and inject the param into context.params + if (!page && slug.includes("/")) { + const idx = slug.indexOf("/"); + const dir = slug.slice(0, idx); + const param = slug.slice(idx + 1); + + // Discover the dynamic segment name from the file system + try { + const entries: string[] = []; + for await (const entry of Deno.readDir(`${basePath}/partials/${dir}`)) { + if (entry.isFile) entries.push(entry.name); + } + const dynFile = entries.find((n) => + n.startsWith("[") && n.endsWith("].tsx") + ); + if (dynFile) { + const paramName = dynFile.slice(1, -5); // "[numEtud].tsx" → "numEtud" + context.params[paramName] = param; + page = (await import(`${basePath}/partials/${dir}/${dynFile}`)).Page; + } + } catch { + // directory doesn't exist or no dynamic file + } + } + + if (!page) { + return context.renderNotFound(); + } + + return page(request, context); + }; +} diff --git a/deno.json b/deno.json index c7f729b..97ab295 100644 --- a/deno.json +++ b/deno.json @@ -9,7 +9,14 @@ "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "build": "deno run -A --unstable-ffi dev.ts build", "preview": "deno run -A --unstable-ffi main.ts", - "update": "deno run -A -r https://fresh.deno.dev/update ." + "update": "deno run -A -r https://fresh.deno.dev/update .", + "test": "deno test -A --no-check tests/", + "test:unit": "deno test -A --no-check tests/unit/", + "test:integration": "deno test -A --no-check tests/integration/", + "test:e2e": "deno test -A --no-check tests/e2e/", + "test:coverage": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/", + "test:coverage:html": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/ --html", + "migrate": "node_modules/.bin/drizzle-kit migrate" }, "lint": { "rules": { @@ -35,6 +42,9 @@ "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "$std/": "https://deno.land/std@0.216.0/", + "@std/assert": "jsr:@std/assert@^1.0.0", + "@std/testing": "jsr:@std/testing@^1.0.0", + "happy-dom": "npm:happy-dom@^16.0.0", "$root/": "./", "$apps/": "./routes/(apps)/" }, diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..aa57f48 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "drizzle-kit"; +import process from "node:process"; + +const url = process.env.DATABASE_URL ?? + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASS}@${ + process.env.POSTGRES_HOST ?? "localhost" + }:${process.env.POSTGRES_PORT ?? 5432}/${process.env.POSTGRES_DB}`; + +export default defineConfig({ + dialect: "postgresql", + schema: "./databases/schema.kit.ts", + out: "./databases/migrations", + dbCredentials: { + url, + ssl: false, + }, +}); diff --git a/env.template b/env.template index 34b3fdc..fbfd38e 100644 --- a/env.template +++ b/env.template @@ -1,2 +1,8 @@ #Local mode, set to true to access admin pages with any users LOCAL=false + +POSTGRES_HOST = db +POSTGRES_PORT = 5432 +POSTGRES_PASS = astrongpass +POSTGRES_USER = postgres +POSTGRES_DB = polympr diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c8abda9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a9b867c --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "PolyMPR CLI - A tool for managing PolyMPR modules"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.pmpr = pkgs.stdenv.mkDerivation { + pname = "pmpr"; + version = "0.1.0"; + src = ./.; + + nativeBuildInputs = [ + pkgs.deno + pkgs.autoPatchelfHook + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + buildPhase = '' + export HOME=$TMPDIR + deno cache toolbox/cli.ts + deno compile -A --output pmpr toolbox/cli.ts + ''; + + installPhase = '' + mkdir -p $out/bin + cp pmpr $out/bin/pmpr + ''; + }; + + packages.default = self.packages.${system}.pmpr; + + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.deno + pkgs.patchelf + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" + export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker) + echo "Welcome to PolyMPR development shell!" + echo "Use 'deno task compile' to build the CLI." + ''; + }; + } + ); +} diff --git a/fresh.gen.ts b/fresh.gen.ts index eeb5302..d119210 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,16 +4,71 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; -import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; +import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx"; +import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts"; +import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; +import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts"; +import * as $_apps_admin_api_modules from "./routes/(apps)/admin/api/modules.ts"; +import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/modules/[idModule].ts"; +import * as $_apps_admin_api_permissions from "./routes/(apps)/admin/api/permissions.ts"; +import * as $_apps_admin_api_roles from "./routes/(apps)/admin/api/roles.ts"; +import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles/[idRole].ts"; +import * as $_apps_admin_api_ue_modules from "./routes/(apps)/admin/api/ue-modules.ts"; +import * as $_apps_admin_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import * as $_apps_admin_api_ues from "./routes/(apps)/admin/api/ues.ts"; +import * as $_apps_admin_api_ues_idUE_ from "./routes/(apps)/admin/api/ues/[idUE].ts"; +import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts"; +import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts"; +import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx"; +import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx"; +import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx"; +import * as $_apps_admin_partials_import_maquette from "./routes/(apps)/admin/partials/import-maquette.tsx"; +import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx"; +import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx"; +import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx"; +import * as $_apps_admin_partials_promotions from "./routes/(apps)/admin/partials/promotions.tsx"; +import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx"; +import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; +import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; +import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; +import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[...slug].tsx"; +import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts"; +import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts"; +import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; -import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; +import * as $_apps_mobility_partials_overview_numEtud_ from "./routes/(apps)/mobility/partials/overview/[numEtud].tsx"; +import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].tsx"; +import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts"; +import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts"; +import * as $_apps_notes_api_modules from "./routes/(apps)/notes/api/modules.ts"; +import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; +import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; +import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; +import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; +import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; +import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; +import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; +import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; +import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[...slug].tsx"; +import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts"; +import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts"; +import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx"; +import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx"; +import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx"; +import * as $_apps_stages_partials_overview_numEtud_ from "./routes/(apps)/stages/partials/overview/[numEtud].tsx"; +import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].tsx"; +import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts"; +import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; +import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts"; +import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts"; +import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; @@ -24,14 +79,28 @@ import * as $_app from "./routes/_app.tsx"; import * as $_middleware from "./routes/_middleware.ts"; import * as $about from "./routes/about.tsx"; import * as $apps from "./routes/apps.tsx"; +import * as $dev_login from "./routes/dev-login.ts"; import * as $index from "./routes/index.tsx"; import * as $login from "./routes/login.tsx"; import * as $logout from "./routes/logout.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; -import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; -import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx"; -import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; +import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx"; +import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx"; +import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx"; +import * as $_apps_admin_islands_AdminPromotions from "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx"; +import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; +import * as $_apps_admin_islands_AdminUEs from "./routes/(apps)/admin/(_islands)/AdminUEs.tsx"; +import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; +import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; +import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; +import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx"; +import * as $_apps_mobility_islands_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx"; +import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; +import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; +import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx"; +import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; +import * as $_apps_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; @@ -41,21 +110,100 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, - "./routes/(apps)/mobility/api/insert_mobility.ts": - $_apps_mobility_api_insert_mobility, + "./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_, + "./routes/(apps)/admin/api/enseignements.ts": + $_apps_admin_api_enseignements, + "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": + $_apps_admin_api_enseignements_idProf_idModule_idPromo_, + "./routes/(apps)/admin/api/example.ts": $_apps_admin_api_example, + "./routes/(apps)/admin/api/modules.ts": $_apps_admin_api_modules, + "./routes/(apps)/admin/api/modules/[idModule].ts": + $_apps_admin_api_modules_idModule_, + "./routes/(apps)/admin/api/permissions.ts": $_apps_admin_api_permissions, + "./routes/(apps)/admin/api/roles.ts": $_apps_admin_api_roles, + "./routes/(apps)/admin/api/roles/[idRole].ts": + $_apps_admin_api_roles_idRole_, + "./routes/(apps)/admin/api/ue-modules.ts": $_apps_admin_api_ue_modules, + "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts": + $_apps_admin_api_ue_modules_idModule_idUE_idPromo_, + "./routes/(apps)/admin/api/ues.ts": $_apps_admin_api_ues, + "./routes/(apps)/admin/api/ues/[idUE].ts": $_apps_admin_api_ues_idUE_, + "./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users, + "./routes/(apps)/admin/api/users/[id].ts": $_apps_admin_api_users_id_, + "./routes/(apps)/admin/index.tsx": $_apps_admin_index, + "./routes/(apps)/admin/modules/[idModule].tsx": + $_apps_admin_modules_idModule_, + "./routes/(apps)/admin/partials/enseignements.tsx": + $_apps_admin_partials_enseignements, + "./routes/(apps)/admin/partials/import-maquette.tsx": + $_apps_admin_partials_import_maquette, + "./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index, + "./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules, + "./routes/(apps)/admin/partials/permissions.tsx": + $_apps_admin_partials_permissions, + "./routes/(apps)/admin/partials/promotions.tsx": + $_apps_admin_partials_promotions, + "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, + "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, + "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, + "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, + "./routes/(apps)/mobility/[...slug].tsx": $_apps_mobility_slug_, + "./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, + "./routes/(apps)/mobility/api/mobilites/[idMob].ts": + $_apps_mobility_api_mobilites_idMob_, + "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts": + $_apps_mobility_api_mobilites_idMob_contrat, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, - "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx": - $_apps_mobility_partials_admin_edit_mobility, "./routes/(apps)/mobility/partials/index.tsx": $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/mobility/partials/overview/[numEtud].tsx": + $_apps_mobility_partials_overview_numEtud_, + "./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_, + "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, + "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": + $_apps_notes_api_ajustements_numEtud_idUE_, + "./routes/(apps)/notes/api/modules.ts": $_apps_notes_api_modules, + "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, + "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": + $_apps_notes_api_notes_numEtud_idModule_, + "./routes/(apps)/notes/api/notes/import-xlsx.ts": + $_apps_notes_api_notes_import_xlsx, + "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, + "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, + "./routes/(apps)/notes/edition/[numEtud].tsx": + $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, + "./routes/(apps)/notes/partials/(admin)/import.tsx": + $_apps_notes_partials_admin_import, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, + "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, + "./routes/(apps)/stages/[...slug].tsx": $_apps_stages_slug_, + "./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages, + "./routes/(apps)/stages/api/stages/[idStage].ts": + $_apps_stages_api_stages_idStage_, + "./routes/(apps)/stages/index.tsx": $_apps_stages_index, + "./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index, + "./routes/(apps)/stages/partials/overview.tsx": + $_apps_stages_partials_overview, + "./routes/(apps)/stages/partials/overview/[numEtud].tsx": + $_apps_stages_partials_overview_numEtud_, + "./routes/(apps)/students/[slug].tsx": $_apps_students_slug_, + "./routes/(apps)/students/api/promotions.ts": + $_apps_students_api_promotions, + "./routes/(apps)/students/api/promotions/[idPromo].ts": + $_apps_students_api_promotions_idPromo_, "./routes/(apps)/students/api/students.ts": $_apps_students_api_students, + "./routes/(apps)/students/api/students/[numEtud].ts": + $_apps_students_api_students_numEtud_, + "./routes/(apps)/students/api/students/import-csv.ts": + $_apps_students_api_students_import_csv, + "./routes/(apps)/students/edit/[numEtud].tsx": + $_apps_students_edit_numEtud_, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -69,6 +217,7 @@ const manifest = { "./routes/_middleware.ts": $_middleware, "./routes/about.tsx": $about, "./routes/apps.tsx": $apps, + "./routes/dev-login.ts": $dev_login, "./routes/index.tsx": $index, "./routes/login.tsx": $login, "./routes/logout.tsx": $logout, @@ -76,12 +225,38 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, - "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": - $_apps_mobility_islands_ConsultMobility, - "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": - $_apps_mobility_islands_EditMobility, - "./routes/(apps)/mobility/(_islands)/ImportFile.tsx": - $_apps_mobility_islands_ImportFile, + "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx": + $_apps_admin_islands_AdminEnseignements, + "./routes/(apps)/admin/(_islands)/AdminModules.tsx": + $_apps_admin_islands_AdminModules, + "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx": + $_apps_admin_islands_AdminPermissions, + "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx": + $_apps_admin_islands_AdminPromotions, + "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": + $_apps_admin_islands_AdminRoles, + "./routes/(apps)/admin/(_islands)/AdminUEs.tsx": + $_apps_admin_islands_AdminUEs, + "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": + $_apps_admin_islands_AdminUsers, + "./routes/(apps)/admin/(_islands)/EditModule.tsx": + $_apps_admin_islands_EditModule, + "./routes/(apps)/admin/(_islands)/EditUser.tsx": + $_apps_admin_islands_EditUser, + "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": + $_apps_admin_islands_ImportMaquette, + "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx": + $_apps_mobility_islands_MobilityOverview, + "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": + $_apps_notes_islands_AdminConsultNotes, + "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": + $_apps_notes_islands_ImportNotes, + "./routes/(apps)/notes/(_islands)/NoteRecap.tsx": + $_apps_notes_islands_NoteRecap, + "./routes/(apps)/notes/(_islands)/NotesView.tsx": + $_apps_notes_islands_NotesView, + "./routes/(apps)/stages/(_islands)/StagesOverview.tsx": + $_apps_stages_islands_StagesOverview, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000..29b5205 --- /dev/null +++ b/openapi.yml @@ -0,0 +1,1536 @@ +openapi: 3.1.0 +info: + title: PolyMPR API + version: 2.0.0 + description: API de gestion des étudiants, notes, mobilités, stages et administration. + +servers: + - url: / + +tags: + - name: Students + - name: Promotions + - name: Users + - name: Roles + - name: Permissions + - name: Modules + - name: Enseignements + - name: UEs + - name: UE_Modules + - name: Notes + - name: Ajustements + - name: Mobilités + - name: Stages + +paths: + # ── Students ────────────────────────────────────────────── + /students/api/students: + get: + tags: [Students] + summary: Liste des étudiants + parameters: + - $ref: "#/components/parameters/idPromoQuery" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Student" + post: + tags: [Students] + summary: Créer un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + + /students/api/students/import-csv: + post: + tags: [Students] + summary: Importer des étudiants par CSV + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idPromo] + properties: + file: + type: string + format: binary + idPromo: + type: string + responses: + "200": + description: Résultat de l'import + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + message: + type: string + + /students/api/students/{numEtud}: + parameters: + - $ref: "#/components/parameters/numEtud" + get: + tags: [Students] + summary: Détail d'un étudiant + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Students] + summary: Modifier un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Students] + summary: Supprimer un étudiant (cascade mobilités et stages) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Promotions ──────────────────────────────────────────── + /students/api/promotions: + get: + tags: [Promotions] + summary: Liste des promotions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Promotion" + post: + tags: [Promotions] + summary: Créer une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + + /students/api/promotions/{idPromo}: + parameters: + - $ref: "#/components/parameters/idPromo" + get: + tags: [Promotions] + summary: Détail d'une promotion + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Promotions] + summary: Modifier une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Promotions] + summary: Supprimer une promotion + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Users ──────────────────────────────────────────────── + /admin/api/users: + get: + tags: [Users] + summary: Liste des utilisateurs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + tags: [Users] + summary: Créer un utilisateur (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "403": + description: Accès refusé + + /admin/api/users/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + tags: [Users] + summary: Détail d'un utilisateur + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Users] + summary: Modifier un utilisateur + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Users] + summary: Supprimer un utilisateur + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Roles ──────────────────────────────────────────────── + /admin/api/roles: + get: + tags: [Roles] + summary: Liste des rôles + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Role" + post: + tags: [Roles] + summary: Créer un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + + /admin/api/roles/{idRole}: + parameters: + - name: idRole + in: path + required: true + schema: + type: integer + get: + tags: [Roles] + summary: Détail d'un rôle + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Roles] + summary: Modifier un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Roles] + summary: Supprimer un rôle + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Permissions ────────────────────────────────────────── + /admin/api/permissions: + get: + tags: [Permissions] + summary: Liste des permissions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + + # ── Modules ─────────────────────────────────────────────── + /admin/api/modules: + get: + tags: [Modules] + summary: Liste des modules + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Module" + post: + tags: [Modules] + summary: Créer un module (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "409": + description: Un module avec cet identifiant existe déjà + + /admin/api/modules/{idModule}: + parameters: + - $ref: "#/components/parameters/idModule" + get: + tags: [Modules] + summary: Détail d'un module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Modules] + summary: Modifier un module + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nom] + properties: + nom: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Modules] + summary: Supprimer un module (cascade notes, ue_modules, enseignements) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Enseignements ─────────────────────────────────────── + /admin/api/enseignements: + get: + tags: [Enseignements] + summary: Liste des enseignements + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Enseignement" + post: + tags: [Enseignements] + summary: Créer un enseignement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnseignementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "409": + description: Cet enseignement existe déjà + + /admin/api/enseignements/{idProf}/{idModule}/{idPromo}: + parameters: + - name: idProf + in: path + required: true + schema: + type: string + - name: idModule + in: path + required: true + schema: + type: string + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [Enseignements] + summary: Détail d'un enseignement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Enseignements] + summary: Supprimer un enseignement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UEs ─────────────────────────────────────────────────── + /admin/api/ues: + get: + tags: [UEs] + summary: Liste des UEs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UE" + post: + tags: [UEs] + summary: Créer une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + + /admin/api/ues/{idUE}: + parameters: + - $ref: "#/components/parameters/idUE" + get: + tags: [UEs] + summary: Détail d'une UE + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UEs] + summary: Modifier une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UEs] + summary: Supprimer une UE + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UE_Modules ──────────────────────────────────────────── + /admin/api/ue-modules: + get: + tags: [UE_Modules] + summary: Liste des associations UE-Module + parameters: + - $ref: "#/components/parameters/idPromoQuery" + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UEModule" + post: + tags: [UE_Modules] + summary: Associer un module à une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UEModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + + /admin/api/ue-modules/{idModule}/{idUE}/{idPromo}: + parameters: + - name: idModule + in: path + required: true + schema: + type: string + - name: idUE + in: path + required: true + schema: + type: integer + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [UE_Modules] + summary: Détail d'une association UE-Module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UE_Modules] + summary: Modifier le coefficient + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [coeff] + properties: + coeff: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UE_Modules] + summary: Supprimer l'association + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Notes ───────────────────────────────────────────────── + /notes/api/notes: + get: + tags: [Notes] + summary: Liste des notes + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idModule + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Note" + post: + tags: [Notes] + summary: Créer une note + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NoteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "409": + description: Note déjà existante pour cet étudiant/module + + /notes/api/notes/import-xlsx: + post: + tags: [Notes] + summary: Importer des notes par fichier XLSX + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idModule] + properties: + file: + type: string + format: binary + idModule: + type: string + responses: + "200": + description: Import réussi + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + student: + type: string + message: + type: string + "400": + description: Fichier invalide ou données corrompues + + /notes/api/notes/{numEtud}/{idModule}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idModule + in: path + required: true + schema: + type: string + get: + tags: [Notes] + summary: Détail d'une note + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Notes] + summary: Modifier une note + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: + type: number + minimum: 0 + maximum: 20 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Notes] + summary: Supprimer une note + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Ajustements ─────────────────────────────────────────── + /notes/api/ajustements: + get: + tags: [Ajustements] + summary: Liste des ajustements + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ajustement" + post: + tags: [Ajustements] + summary: Créer un ajustement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AjustementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + + /notes/api/ajustements/{numEtud}/{idUE}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idUE + in: path + required: true + schema: + type: integer + get: + tags: [Ajustements] + summary: Détail d'un ajustement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Ajustements] + summary: Modifier un ajustement + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [valeur] + properties: + valeur: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Ajustements] + summary: Supprimer un ajustement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Mobilités ───────────────────────────────────────────── + /mobility/api/mobilites: + get: + tags: [Mobilités] + summary: Liste des mobilités + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mobilite" + post: + tags: [Mobilités] + summary: Créer une mobilité + description: > + Les étudiants ne peuvent pas définir idStage ni changer le status + (reste contracts_received). Les mobilités liées à un stage sont + automatiquement validées. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MobiliteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Champs requis manquants ou invalides + + /mobility/api/mobilites/{idMob}: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Détail d'une mobilité + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Mobilités] + summary: Modifier une mobilité (employee only) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer une mobilité (employee only, supprime aussi le contrat) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + /mobility/api/mobilites/{idMob}/contrat: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Télécharger le contrat PDF + responses: + "200": + description: Fichier PDF + content: + application/pdf: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + post: + tags: [Mobilités] + summary: Uploader un contrat PDF + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [contrat] + properties: + contrat: + type: string + format: binary + description: Fichier PDF du contrat + responses: + "200": + description: Mobilité mise à jour avec le nom du fichier + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Fichier manquant ou pas un PDF + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer le contrat (employee only) + responses: + "204": + description: Contrat supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + # ── Stages ──────────────────────────────────────────────── + /stages/api/stages: + get: + tags: [Stages] + summary: Liste des stages + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Stage" + post: + tags: [Stages] + summary: Créer un stage (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StageCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "400": + description: Champs requis manquants + "403": + description: Accès refusé + + /stages/api/stages/{idStage}: + parameters: + - name: idStage + in: path + required: true + schema: + type: integer + get: + tags: [Stages] + summary: Détail d'un stage + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Stages] + summary: Modifier un stage (employee only, synchronise la durée sur la mobilité liée) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Stages] + summary: Supprimer un stage (employee only, cascade mobilités liées) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + +# ── Components ──────────────────────────────────────────────── +components: + parameters: + numEtud: + name: numEtud + in: path + required: true + schema: + type: integer + example: 21212006 + idPromo: + name: idPromo + in: path + required: true + schema: + type: string + example: 4AFISE25/26 + idPromoQuery: + name: idPromo + in: query + schema: + type: string + example: 4AFISE25/26 + idModule: + name: idModule + in: path + required: true + schema: + type: string + idUE: + name: idUE + in: path + required: true + schema: + type: integer + + responses: + NotFound: + description: Ressource introuvable + content: + application/json: + schema: + type: object + properties: + error: + type: string + + schemas: + # ── Student ── + Student: + type: object + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + StudentCreate: + type: object + required: [numEtud, nom, prenom, idPromo] + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + + # ── Promotion ── + Promotion: + type: object + properties: + id: + type: string + annee: + type: string + PromotionCreate: + type: object + required: [id, annee] + properties: + id: + type: string + annee: + type: string + + # ── User ── + User: + type: object + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + nullable: true + UserCreate: + type: object + required: [id, nom, prenom] + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + + # ── Role ── + Role: + type: object + properties: + id: + type: integer + nom: + type: string + RoleCreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── Permission ── + Permission: + type: object + properties: + id: + type: string + nom: + type: string + + # ── Module ── + Module: + type: object + properties: + id: + type: string + nom: + type: string + ModuleCreate: + type: object + required: [id, nom] + properties: + id: + type: string + nom: + type: string + + # ── Enseignement ── + Enseignement: + type: object + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + EnseignementCreate: + type: object + required: [idProf, idModule, idPromo] + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + + # ── UE ── + UE: + type: object + properties: + id: + type: integer + nom: + type: string + UECreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── UE_Module ── + UEModule: + type: object + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + UEModuleCreate: + type: object + required: [idModule, idUE, idPromo, coeff] + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + + # ── Note ── + Note: + type: object + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + NoteCreate: + type: object + required: [numEtud, idModule, note] + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + + # ── Ajustement ── + Ajustement: + type: object + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + AjustementCreate: + type: object + required: [numEtud, idUE, valeur] + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + + # ── Mobilité ── + MobilityStatus: + type: string + enum: [contracts_received, under_revision, done, validated, canceled] + Mobilite: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + contratMob: + type: string + nullable: true + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + MobiliteCreate: + type: object + required: [numEtud, duree] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + ecole: + type: string + pays: + type: string + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + + # ── Stage ── + Stage: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + nomEntreprise: + type: string + mission: + type: string + nullable: true + StageCreate: + type: object + required: [numEtud, duree, nomEntreprise] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c2ff0c --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "dotenv": "^17.4.0", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "pg": "^8.20.0" + }, + "devDependencies": { + "@types/pg": "^8.20.0", + "tsx": "^4.21.0" + } +} diff --git a/routes/(_components)/Footer.tsx b/routes/(_components)/Footer.tsx index f71930f..ee9a6c1 100644 --- a/routes/(_components)/Footer.tsx +++ b/routes/(_components)/Footer.tsx @@ -6,7 +6,7 @@ export default function Footer(_props: FooterProps) { return (

- © 2025 PolyMPR - About + © 2026 PolyMPR - About

); diff --git a/routes/(_components)/Header.tsx b/routes/(_components)/Header.tsx index 34853ad..ec5573b 100644 --- a/routes/(_components)/Header.tsx +++ b/routes/(_components)/Header.tsx @@ -11,6 +11,14 @@ export default function Header(props: HeaderProps) { ); diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index f30f19f..a671134 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,14 +21,29 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = properties.pages; - if ( - context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true" - ) { + const isStudent = + context.state.session.eduPersonPrimaryAffiliation === "student"; + const isLocal = Deno.env.get("LOCAL") === "true"; + + // Block students from accessing employeeOnly modules entirely + if (isStudent && properties.employeeOnly) { + return new Response(null, { status: 403 }); + } + + context.state.availablePages = { ...properties.pages }; + + if (isStudent) { + // Students only see studentOnly pages (+ non-restricted pages) properties.adminOnly.forEach((page) => delete context.state.availablePages[page] ); + } else if (isLocal) { + // In local mode, employees see all pages (admin + student) + } else { + // In prod, employees don't see studentOnly pages + properties.studentOnly?.forEach((page) => + delete context.state.availablePages[page] + ); } return await context.next(); diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx new file mode 100644 index 0000000..3b76991 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -0,0 +1,331 @@ +import { useEffect, useState } from "preact/hooks"; + +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +export default function AdminEnseignements() { + const [enseignements, setEnseignements] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [filterPromo, setFilterPromo] = useState(""); + const [filterModule, setFilterModule] = useState(""); + const [filterEnseignant, setFilterEnseignant] = useState(""); + + // Add form + const [showAdd, setShowAdd] = useState(false); + const [addPromo, setAddPromo] = useState(""); + const [addModule, setAddModule] = useState(""); + const [addProf, setAddProf] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [eRes, mRes, pRes] = await Promise.all([ + fetch("/admin/api/enseignements"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!eRes.ok) throw new Error("Impossible de charger les enseignements"); + setEnseignements(await eRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function deleteEnseignement( + idProf: string, + idModule: string, + idPromo: string, + ) { + if ( + !confirm( + `Supprimer l'assignation ${idProf} → ${idModule} / ${idPromo} ?`, + ) + ) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(idProf)}/${ + encodeURIComponent(idModule) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addProf.trim() || !addModule || !addPromo) { + setAddError("Tous les champs sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: addProf.trim(), + idModule: addModule, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddProf(""); + setAddModule(""); + setAddPromo(""); + setShowAdd(false); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const filtered = enseignements.filter((e) => { + const matchPromo = !filterPromo || e.idPromo === filterPromo; + const matchModule = !filterModule || e.idModule === filterModule; + const matchEns = !filterEnseignant || + e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase()); + return matchPromo && matchModule && matchEns; + }); + + return ( +
+

Assignations Enseignant → ECUE / Promo

+ + {error &&

{error}

} + +
+ + + + setFilterEnseignant((e.target as HTMLInputElement).value)} + /> + + +
+ + {showAdd && ( + + )} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((e) => { + const mod = moduleMap[e.idModule]; + return ( + + + + + + + ); + })} + +
PromoECUEEnseignant (User.id)Actions
+ Aucun enseignement trouvé +
+ {e.idPromo} + + {mod ? `${mod.id} – ${mod.nom}` : e.idModule} + {e.idProf} +
+ +
+
+
+ )} + +
+

+ Un même ECUE peut être enseigné par plusieurs utilisateurs sur une + même promo. +

+

+ Clé composite = idProf (User.Id) + idModule + idPromo +

+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx new file mode 100644 index 0000000..2ce6ee4 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type User = { id: string; nom: string; prenom: string }; + +export default function AdminModules() { + const [modules, setModules] = useState([]); + const [enseignements, setEnseignements] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + const [filterNom, setFilterNom] = useState(""); + + async function load() { + try { + const [mRes, eRes, uRes] = await Promise.all([ + fetch("/admin/api/modules"), + fetch("/admin/api/enseignements"), + fetch("/admin/api/users"), + ]); + if (!mRes.ok) throw new Error("Impossible de charger les ECUEs"); + setModules(await mRes.json()); + if (eRes.ok) setEnseignements(await eRes.json()); + if (uRes.ok) setUsers(await uRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createModule() { + if (!newId.trim() || !newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: newId.trim(), nom: newNom.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteModule(id: string) { + if (!confirm(`Supprimer l'ECUE ${id} ?`)) return; + try { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const userMap = Object.fromEntries( + users.map((u) => [u.id, u]), + ); + + function enseignantsForModule(moduleId: string): string { + const profs = [ + ...new Set( + enseignements + .filter((e) => e.idModule === moduleId) + .map((e) => e.idProf), + ), + ]; + if (profs.length === 0) return ""; + return profs + .map((id) => { + const u = userMap[id]; + return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id; + }) + .join(", "); + } + + const filtered = modules.filter((m) => + !filterNom || + `${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase()) + ); + + return ( +
+

Gestion des ECUEs

+ + {error &&

{error}

} + +
+ setFilterNom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement...

+ : ( +
+ + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((m) => { + const profs = enseignantsForModule(m.id); + return ( + + + + + + + ); + })} + +
id (code)Nom de l'ECUEEnseignants assignesActions
+ Aucun ECUE enregistré +
{m.id}{m.nom} + {profs + ? ( + + {profs} + + ) + : --} + +
+ + + + {" "} + edit + + +
+
+
+ )} + + {/* Nouvel ECUE */} +
+

Nouvel ECUE

+
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 8rem; max-width: 10rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx new file mode 100644 index 0000000..79ed125 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "preact/hooks"; + +type Perm = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; + +const ROLE_COLORS = [ + "#22c55e", + "#d4a017", + "#e07020", + "#8b5cf6", + "#06b6d4", + "#ec4899", +]; + +function roleColor(roleId: number): string { + return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length]; +} + +export default function AdminPermissions() { + const [permissions, setPermissions] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const [pRes, rRes] = await Promise.all([ + fetch("/admin/api/permissions"), + fetch("/admin/api/roles"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les permissions"); + setPermissions(await pRes.json()); + if (rRes.ok) setRoles(await rRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + function rolesForPerm(permId: string): Role[] { + return roles.filter((r) => r.permissions.includes(permId)); + } + + const MAX_ROLE_CHIPS = 2; + + return ( +
+

Permissions

+ +
+

+ Les permissions sont définies statiquement par le serveur. +

+

+ Elles ne peuvent pas être créées ou supprimées via l'API. +

+
+ + {error &&

{error}

} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {permissions.map((p) => { + const associated = rolesForPerm(p.id); + const shown = associated.slice(0, MAX_ROLE_CHIPS); + const overflow = associated.length - MAX_ROLE_CHIPS; + return ( + + + + + + ); + })} + +
idPermissionnomPermissionRôles associés
+ + {p.id} + + {p.nom} +
+ {shown.map((r) => ( + + {r.nom} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + {associated.length === 0 && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminPromotions.tsx b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx new file mode 100644 index 0000000..68c71c6 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState } from "preact/hooks"; + +type Promotion = { id: string; annee: string | null }; +type Student = { numEtud: number; idPromo: string }; + +function parsePromo(id: string) { + const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/); + if (!m) return { annee: id, filiere: "?", anneeSco: "?" }; + return { annee: m[1], filiere: m[2], anneeSco: m[3] }; +} + +const ANNEES = ["3A", "4A", "5A"]; +const FILIERES = ["FISE", "FISA"]; + +export default function AdminPromotions() { + const [promos, setPromos] = useState([]); + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + + // PromoBuilder state + const [selectedAnnee, setSelectedAnnee] = useState("4A"); + const [selectedFiliere, setSelectedFiliere] = useState("FISE"); + const [anneeSco, setAnneeSco] = useState(""); + + const generatedId = anneeSco.trim() + ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}` + : ""; + + async function load() { + try { + const [pRes, sRes] = await Promise.all([ + fetch("/students/api/promotions"), + fetch("/students/api/students"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les promotions"); + setPromos(await pRes.json()); + if (sRes.ok) setStudents(await sRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createPromo() { + if (!generatedId) return; + setCreating(true); + try { + const res = await fetch("/students/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idPromo: generatedId, + annee: selectedAnnee, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAnneeSco(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deletePromo(id: string) { + if (studentCount(id) > 0) { + setError( + `Impossible de supprimer ${id} : des étudiants y sont encore assignés. Réassignez-les d'abord.`, + ); + return; + } + if ( + !confirm(`Supprimer la promotion ${id} et toutes ses données liées ?`) + ) { + return; + } + try { + const res = await fetch( + `/students/api/promotions/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Suppression échouée"); + } + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + function studentCount(idPromo: string) { + return students.filter((s) => s.idPromo === idPromo).length; + } + + return ( +
+

Gestion des Promotions

+ + {error &&

{error}

} + + {/* PromoBuilder */} +
+

Créer une promotion

+

+ idPromo est généré automatiquement +

+ +
+
+ +
+ {ANNEES.map((a) => ( + + ))} +
+
+ +
+ +
+ {FILIERES.map((f) => ( + + ))} +
+
+ +
+ + setAnneeSco((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> +
+
+ +
+
+ + idPromo généré : + + + {generatedId || "—"} + +
+ +
+
+ + {/* Existing promotions table */} +

+ Promotions existantes +

+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + + {promos.length === 0 + ? ( + + + + ) + : promos.map((p) => { + const parsed = parsePromo(p.id); + const count = studentCount(p.id); + return ( + + + + + + + + + ); + })} + +
idPromoAnnéeFilièreAnnée sco.Nb étudiantsActions
+ Aucune promotion enregistrée +
+ {p.id} + {parsed.annee} + {parsed.filiere} + {parsed.anneeSco} + {count} étudiant{count !== 1 ? "s" : ""} + + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx new file mode 100644 index 0000000..853a1e6 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState } from "preact/hooks"; + +type Role = { id: number; nom: string; permissions: string[] }; +type Perm = { id: string; nom: string }; + +const MAX_CHIPS = 3; + +export default function AdminRoles() { + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + + // Manage-perms sub-view + const [managingRole, setManagingRole] = useState(null); + const [editPerms, setEditPerms] = useState>(new Set()); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + async function load() { + try { + const [rRes, pRes] = await Promise.all([ + fetch("/admin/api/roles"), + fetch("/admin/api/permissions"), + ]); + if (!rRes.ok) throw new Error("Impossible de charger les rôles"); + setRoles(await rRes.json()); + if (pRes.ok) setPermissions(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createRole() { + if (!newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newNom.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteRole(id: number) { + if (!confirm("Supprimer ce rôle ?")) return; + try { + const res = await fetch(`/admin/api/roles/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + function openManage(role: Role) { + setManagingRole(role); + setEditPerms(new Set(role.permissions)); + setSaveError(null); + } + + function togglePerm(permId: string) { + setEditPerms((prev) => { + const next = new Set(prev); + if (next.has(permId)) next.delete(permId); + else next.add(permId); + return next; + }); + } + + async function savePerms() { + if (!managingRole) return; + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/admin/api/roles/${managingRole.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: managingRole.nom, + permissions: [...editPerms], + }), + }); + if (!res.ok) throw new Error("Enregistrement échoué"); + await load(); + setManagingRole(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + // ---- Manage-perms view ---- + if (managingRole) { + const activeCount = editPerms.size; + return ( +
+ { + e.preventDefault(); + setManagingRole(null); + }} + > + ← Retour à la liste des rôles + +

+ Permissions du rôle – {managingRole.nom} +

+ + {saveError &&

{saveError}

} + +
+
+ idRole : {managingRole.id} + + {managingRole.nom} + + + {activeCount} permission{activeCount !== 1 ? "s" : ""} active + {activeCount !== 1 ? "s" : ""} + +
+ +
+ +
+ + Permissions disponibles + + + Activer = inclure dans le rôle + +
+ +
+ {permissions.map((p) => { + const active = editPerms.has(p.id); + return ( + + ); + })} +
+
+ ); + } + + const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom])); + + // ---- Main list view ---- + return ( +
+

Gestion des Rôles

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createRole()} + style="min-width: 14rem" + /> + +
+ + {loading + ?

Chargement...

+ : ( +
+ + + + + + + + + + + {roles.length === 0 + ? ( + + + + ) + : roles.map((r) => { + const shown = r.permissions.slice(0, MAX_CHIPS); + const overflow = r.permissions.length - MAX_CHIPS; + return ( + + + + + + + ); + })} + +
idRoleNom du rôlePermissionsActions
+ Aucun rôle enregistré +
{r.id} + {r.nom} + +
+ {shown.map((p) => ( + + {permMap[p] ?? p} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminUEs.tsx b/routes/(apps)/admin/(_islands)/AdminUEs.tsx new file mode 100644 index 0000000..5bb7a57 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminUEs.tsx @@ -0,0 +1,516 @@ +import { useEffect, useState } from "preact/hooks"; + +type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +export default function AdminUEs() { + const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [selectedUe, setSelectedUe] = useState(null); + const [filterPromo, setFilterPromo] = useState(""); + + // New UE form + const [newUeNom, setNewUeNom] = useState(""); + const [creatingUe, setCreatingUe] = useState(false); + + // Add UE-module form + const [addModuleId, setAddModuleId] = useState(""); + const [addPromoId, setAddPromoId] = useState(""); + const [addCoeff, setAddCoeff] = useState("1"); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + // Inline coeff editing + const [editingCoeff, setEditingCoeff] = useState(null); + const [editCoeffValue, setEditCoeffValue] = useState(""); + + async function load() { + try { + const [uRes, umRes, mRes, pRes] = await Promise.all([ + fetch("/admin/api/ues"), + fetch("/admin/api/ue-modules"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!uRes.ok) throw new Error("Impossible de charger les UEs"); + const uesData: UE[] = await uRes.json(); + setUes(uesData); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + // Keep selection in sync + setSelectedUe((prev) => + prev ? uesData.find((u) => u.id === prev.id) ?? null : null + ); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUE() { + if (!newUeNom.trim()) return; + setCreatingUe(true); + try { + const res = await fetch("/admin/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newUeNom.trim() }), + }); + if (!res.ok) throw new Error("Création échouée"); + setNewUeNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreatingUe(false); + } + } + + async function deleteUE(ue: UE) { + if (!confirm(`Supprimer la UE "${ue.nom}" et tous ses liens ?`)) return; + try { + const res = await fetch(`/admin/api/ues/${ue.id}`, { method: "DELETE" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Suppression échouée"); + } + if (selectedUe?.id === ue.id) setSelectedUe(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function deleteUeModule( + idModule: string, + idUE: number, + idPromo: string, + ) { + if (!confirm("Supprimer cet ECUE de la UE ?")) return; + try { + const res = await fetch( + `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addUeModule() { + if (!selectedUe || !addModuleId || !addPromoId) { + setAddError("ECUE et Promo sont requis"); + return; + } + const coeff = parseFloat(addCoeff); + if (isNaN(coeff) || coeff <= 0) { + setAddError("Coefficient invalide"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idModule: addModuleId, + idUE: selectedUe.id, + idPromo: addPromoId, + coeff, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddModuleId(""); + setAddPromoId(""); + setAddCoeff("1"); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + async function updateCoeff( + idModule: string, + idUE: number, + idPromo: string, + coeff: number, + ) { + try { + const res = await fetch( + `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coeff }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setEditingCoeff(null); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + // Filter UEs by promo: keep UEs that have at least one ue_module for that promo + const filteredUes = filterPromo + ? ues.filter((ue) => + ueModules.some((um) => um.idUE === ue.id && um.idPromo === filterPromo) + ) + : ues; + + const selectedUeModules = selectedUe + ? ueModules.filter((um) => um.idUE === selectedUe.id) + : []; + + return ( +
+

Gestion des UEs

+

+ UE = Unité d'Enseignement regroupant plusieurs ECUEs +

+ + {error &&

{error}

} + + {loading + ?

Chargement…

+ : ( +
+ {/* Left panel – UE list */} +
+
+

UEs existantes

+ +
+ + setNewUeNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + style="min-width: 0; flex: 1" + /> +
+ +
+ {filteredUes.map((ue) => ( +
+ { + setSelectedUe(ue); + setAddError(null); + }} + > + {ue.nom} + + +
+ ))} + {filteredUes.length === 0 && ( +

+ {filterPromo ? "Aucune UE pour cette promo" : "Aucune UE"} +

+ )} +
+
+
+ + {/* Right panel – UE detail */} +
+ {selectedUe + ? ( +
+

{selectedUe.nom}

+

+ ECUEs assignés (UE_Module) +

+
+ + + + + + + + + + + {selectedUeModules.length === 0 + ? ( + + + + ) + : selectedUeModules.map((um) => { + const mod = moduleMap[um.idModule]; + return ( + + + + + + + ); + })} + +
ECUEPromoCoeffActions
+ Aucun ECUE assigné +
+ {mod + ? `${mod.id} – ${mod.nom}` + : um.idModule} + + {um.idPromo} + { + const key = + `${um.idModule}-${um.idUE}-${um.idPromo}`; + setEditingCoeff(key); + setEditCoeffValue(String(um.coeff)); + }} + style="cursor: pointer" + > + {editingCoeff === + `${um.idModule}-${um.idUE}-${um.idPromo}` + ? ( + + setEditCoeffValue( + (e.target as HTMLInputElement) + .value, + )} + onBlur={() => { + const v = parseFloat( + editCoeffValue, + ); + if (!isNaN(v) && v > 0) { + updateCoeff( + um.idModule, + um.idUE, + um.idPromo, + v, + ); + } else { + setEditingCoeff(null); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement) + .blur(); + } + if (e.key === "Escape") { + setEditingCoeff(null); + } + }} + /> + ) + : um.coeff} + + +
+
+ +

+ Ajouter un ECUE à cette UE +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + + setAddCoeff((e.target as HTMLInputElement).value)} + style="min-width: 5rem; max-width: 6rem" + /> + +
+
+ ) + : ( +
+

+ Sélectionnez une UE pour voir ses ECUEs +

+
+ )} +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx new file mode 100644 index 0000000..0ca35b6 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState } from "preact/hooks"; + +type User = { id: string; nom: string; prenom: string; idRole: number | null }; +type Role = { id: number; nom: string }; + +const ROLE_COLORS = [ + "#22c55e", + "#d4a017", + "#e07020", + "#8b5cf6", + "#06b6d4", + "#ec4899", +]; + +function roleColor(roleId: number): string { + return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length]; +} + +export default function AdminUsers() { + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [newId, setNewId] = useState(""); + const [newNom, setNewNom] = useState(""); + const [newPrenom, setNewPrenom] = useState(""); + const [newIdRole, setNewIdRole] = useState(""); + const [creating, setCreating] = useState(false); + + const [filterNom, setFilterNom] = useState(""); + const [filterRole, setFilterRole] = useState(""); + + async function load() { + try { + const [uRes, rRes] = await Promise.all([ + fetch("/admin/api/users"), + fetch("/admin/api/roles"), + ]); + if (!uRes.ok) throw new Error("Impossible de charger les utilisateurs"); + setUsers(await uRes.json()); + if (rRes.ok) setRoles(await rRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUser() { + if (!newId.trim() || !newNom.trim() || !newPrenom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/users", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: newId.trim(), + nom: newNom.trim(), + prenom: newPrenom.trim(), + idRole: newIdRole ? Number(newIdRole) : null, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewNom(""); + setNewPrenom(""); + setNewIdRole(""); + setShowCreate(false); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteUser(id: string) { + if (!confirm(`Supprimer l'utilisateur ${id} ?`)) return; + try { + const res = await fetch(`/admin/api/users/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom])); + + const filtered = users.filter((u) => { + const matchNom = !filterNom || + `${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes( + filterNom.toLowerCase(), + ); + const matchRole = !filterRole || String(u.idRole) === filterRole; + return matchNom && matchRole; + }); + + return ( +
+

Gestion des Utilisateurs

+ + {error &&

{error}

} + +
+ setFilterNom((e.target as HTMLInputElement).value)} + /> + + +
+ + {/* Creation modal */} + {showCreate && ( + + )} + + {loading + ?

Chargement...

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((u) => ( + + + + + + + + ))} + +
id (login)NomPrénomRôle(s)Actions
+ Aucun utilisateur trouvé +
{u.id}{u.nom}{u.prenom} + {u.idRole + ? ( + + {roleMap[u.idRole] ?? `#${u.idRole}`} + + ) + : --} + +
+ + + + {" "} + edit + + +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/EditModule.tsx b/routes/(apps)/admin/(_islands)/EditModule.tsx new file mode 100644 index 0000000..1bd554e --- /dev/null +++ b/routes/(apps)/admin/(_islands)/EditModule.tsx @@ -0,0 +1,344 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type User = { id: string; nom: string; prenom: string }; +type Promo = { id: string; annee: string }; + +type Props = { moduleId: string }; + +export default function EditModule({ moduleId }: Props) { + const [mod, setMod] = useState(null); + const [enseignements, setEnseignements] = useState([]); + const [users, setUsers] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + const [nom, setNom] = useState(""); + + // Add enseignement + const [addProf, setAddProf] = useState(""); + const [addPromo, setAddPromo] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [mRes, eRes, uRes, pRes] = await Promise.all([ + fetch(`/admin/api/modules/${encodeURIComponent(moduleId)}`), + fetch("/admin/api/enseignements"), + fetch("/admin/api/users"), + fetch("/students/api/promotions"), + ]); + if (!mRes.ok) throw new Error("ECUE introuvable"); + const m: Module = await mRes.json(); + setMod(m); + setNom(m.nom); + if (eRes.ok) { + const all: Enseignement[] = await eRes.json(); + setEnseignements(all.filter((e) => e.idModule === moduleId)); + } + if (uRes.ok) setUsers(await uRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [moduleId]); + + async function saveInfos() { + if (!mod) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(moduleId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: nom.trim() }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + const updated: Module = await res.json(); + setMod(updated); + setSaveMsg("ECUE enregistré."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteModule() { + if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return; + try { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(moduleId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/admin/modules"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addProf || !addPromo) { + setAddError("Enseignant et Promo sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: addProf, + idModule: moduleId, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddProf(""); + setAddPromo(""); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + async function removeEnseignement(idProf: string, idPromo: string) { + if (!confirm("Retirer cet enseignement ?")) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(idProf)}/${ + encodeURIComponent(moduleId) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + if (error && !mod) { + return ( +
+

{error}

+
+ ); + } + + if (!mod) return null; + + return ( +
+ + ← Retour a la liste + + +

+ ECUE -- {mod.id} +

+ +
+ {mod.id} + {mod.nom} +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Infos */} +
+

Informations

+
+
+ + +
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ + {/* Section 2: Enseignements */} +
+

Enseignants assignes

+ + {enseignements.length > 0 + ? ( +
+ + + + + + + + + + {enseignements.map((e) => { + const u = userMap[e.idProf]; + return ( + + + + + + ); + })} + +
EnseignantPromoActions
+ {u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf} + + {e.idPromo} + + +
+
+ ) + : ( +

+ Aucun enseignant assigne. +

+ )} + +

+ Ajouter un enseignant +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/EditUser.tsx b/routes/(apps)/admin/(_islands)/EditUser.tsx new file mode 100644 index 0000000..2254461 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/EditUser.tsx @@ -0,0 +1,391 @@ +import { useEffect, useState } from "preact/hooks"; + +type User = { id: string; nom: string; prenom: string; idRole: number | null }; +type Role = { id: number; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +type Props = { userId: string }; + +export default function EditUser({ userId }: Props) { + const [user, setUser] = useState(null); + const [roles, setRoles] = useState([]); + const [enseignements, setEnseignements] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + const [nom, setNom] = useState(""); + const [prenom, setPrenom] = useState(""); + const [idRole, setIdRole] = useState(""); + + // Add enseignement form + const [addModule, setAddModule] = useState(""); + const [addPromo, setAddPromo] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [uRes, rRes, eRes, mRes, pRes] = await Promise.all([ + fetch(`/admin/api/users/${encodeURIComponent(userId)}`), + fetch("/admin/api/roles"), + fetch("/admin/api/enseignements"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!uRes.ok) throw new Error("Utilisateur introuvable"); + const u: User = await uRes.json(); + setUser(u); + setNom(u.nom); + setPrenom(u.prenom); + setIdRole(u.idRole !== null ? String(u.idRole) : ""); + if (rRes.ok) setRoles(await rRes.json()); + if (eRes.ok) { + const allEns: Enseignement[] = await eRes.json(); + setEnseignements(allEns.filter((e) => e.idProf === userId)); + } + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [userId]); + + async function saveInfos() { + if (!user) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch( + `/admin/api/users/${encodeURIComponent(userId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: nom.trim(), + prenom: prenom.trim(), + idRole: idRole ? Number(idRole) : null, + }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + const updated: User = await res.json(); + setUser(updated); + setSaveMsg("Informations enregistrées."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteUser() { + if (!confirm(`Supprimer définitivement l'utilisateur ${userId} ?`)) return; + try { + const res = await fetch( + `/admin/api/users/${encodeURIComponent(userId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/admin/users"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addModule || !addPromo) { + setAddError("ECUE et Promo sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: userId, + idModule: addModule, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddModule(""); + setAddPromo(""); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + async function removeEnseignement(idModule: string, idPromo: string) { + if (!confirm("Retirer cet enseignement ?")) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(userId)}/${ + encodeURIComponent(idModule) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom])); + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + if (error && !user) { + return ( +
+

{error}

+
+ ); + } + + if (!user) return null; + + return ( +
+ + ← Retour a la liste + + +

+ Edition -- {user.prenom} {user.nom} +

+ +
+ {user.id} + + {user.idRole + ? (roleMap[user.idRole] ?? `Role #${user.idRole}`) + : "Aucun role"} + +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Informations generales */} +
+

Informations generales

+ +
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+ + setPrenom((e.target as HTMLInputElement).value)} + /> +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {/* Section 2: Enseignements */} +
+

Enseignements

+

+ ECUEs enseignes par cet utilisateur +

+ + {enseignements.length > 0 + ? ( +
+ + + + + + + + + + {enseignements.map((e) => { + const mod = moduleMap[e.idModule]; + return ( + + + + + + ); + })} + +
ECUEPromoActions
+ {mod ? `${mod.id} -- ${mod.nom}` : e.idModule} + + {e.idPromo} + + +
+
+ ) + : ( +

+ Aucun enseignement assigne. +

+ )} + +

+ Ajouter un enseignement +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx new file mode 100644 index 0000000..5a03b6e --- /dev/null +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -0,0 +1,557 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; +import { useEffect, useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; + +type ParsedUE = { + code: string | null; + name: string; + ects: number | null; + modules: ParsedModule[]; +}; + +type ParsedModule = { + code: string; + name: string; + coeff: number; +}; + +type ParsedYear = { + label: string; + ues: ParsedUE[]; +}; + +type Promo = { id: string; annee: string | null }; + +function parseMaquette(workbook: XLSX.WorkBook): ParsedYear[] { + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + + const years: ParsedYear[] = []; + let currentYear: ParsedYear | null = null; + let currentUE: ParsedUE | null = null; + let moduleIndex = 0; + + for (const row of rows) { + if (!row || row.length === 0) continue; + + const col0 = row[0] != null ? String(row[0]).trim() : ""; + + // Detect year row: INFO3A, INFO4A, INFO5A, INFOAPP3A, INFOAPP4A, etc. + if (/^(INFO|INFOAPP)\s*\d+A$/i.test(col0)) { + currentYear = { label: col0, ues: [] }; + years.push(currentYear); + currentUE = null; + continue; + } + + // Detect UE row: col[0] === "UE" or starts with "UE" (e.g., "UE51") + if (col0 === "UE" || (col0.startsWith("UE") && /^UE\d/.test(col0))) { + const ueCode = row[1] != null ? String(row[1]).trim() : null; + const ueName = row[2] != null ? String(row[2]).trim() : "UE sans nom"; + const ects = typeof row[4] === "number" ? row[4] : null; + + currentUE = { code: ueCode, name: ueName, ects, modules: [] }; + if (currentYear) { + currentYear.ues.push(currentUE); + } else { + // No year detected yet — create a default one + currentYear = { label: "Maquette", ues: [currentUE] }; + years.push(currentYear); + } + moduleIndex = 0; + continue; + } + + // Detect semester header rows — just skip, don't reset UE + if (/^SEM\s*\d/i.test(col0)) { + currentUE = null; + continue; + } + + // Detect module row: inside a UE, col[3] has a name, col[5] is a number (coeff) + if (currentUE && row[3] != null && typeof row[5] === "number") { + const modName = String(row[3]).trim(); + if (!modName) continue; + + let modCode = row[1] != null ? String(row[1]).trim() : ""; + if (!modCode) { + const uePrefix = (currentUE.code || "MOD").replace(/[^A-Z0-9]/gi, ""); + modCode = `${uePrefix}_${String(moduleIndex).padStart(2, "0")}`; + } + + currentUE.modules.push({ code: modCode, name: modName, coeff: row[5] }); + moduleIndex++; + } + } + + return years; +} + +export default function ImportMaquette() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const importResult = useSignal(null); + const preview = useSignal(null); + const promos = useSignal([]); + // Map: year label -> selected promo id + const yearPromos = useSignal>({}); + // Inline promo creation + const newPromoId = useSignal(""); + const newPromoAnnee = useSignal(""); + const creatingPromo = useSignal(false); + const inputRef = useRef(null); + + useEffect(() => { + fetch("/students/api/promotions") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => (promos.value = data)); + }, []); + + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; + return; + } + file.value = f; + error.value = null; + importResult.value = null; + preview.value = null; + yearPromos.value = {}; + + f.arrayBuffer().then((buf) => { + try { + const wb = XLSX.read(buf, { type: "array" }); + preview.value = parseMaquette(wb); + } catch { + error.value = "Impossible de lire le fichier."; + } + }); + } + + async function createPromo() { + if (!newPromoId.value.trim() || !newPromoAnnee.value.trim()) return; + creatingPromo.value = true; + try { + const res = await fetch("/students/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idPromo: newPromoId.value.trim(), + annee: newPromoAnnee.value.trim(), + }), + }); + if (res.ok) { + const created = await res.json(); + promos.value = [...promos.value, { + id: created.id, + annee: created.annee, + }]; + newPromoId.value = ""; + newPromoAnnee.value = ""; + } else { + error.value = "Erreur lors de la creation de la promotion."; + } + } finally { + creatingPromo.value = false; + } + } + + function setYearPromo(yearLabel: string, promoId: string) { + yearPromos.value = { ...yearPromos.value, [yearLabel]: promoId }; + } + + // Check that at least one year has a promo assigned + function canImport(): boolean { + if (!preview.value || uploading.value) return false; + return preview.value.some((y) => yearPromos.value[y.label]); + } + + async function doImport() { + if (!preview.value) return; + uploading.value = true; + error.value = null; + importResult.value = null; + + let added = 0; + let ignored = 0; + let errCount = 0; + const details: ImportDetail[] = []; + + try { + for (const year of preview.value) { + const promoId = yearPromos.value[year.label]; + if (!promoId) { + ignored += year.ues.reduce((s, ue) => s + ue.modules.length + 1, 0); + details.push({ + type: "error", + message: `${year.label} : ignoree (pas de promo selectionnee)`, + }); + continue; + } + + for (const ue of year.ues) { + const ueRes = await fetch("/admin/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: ue.name }), + }); + if (!ueRes.ok) { + errCount++; + details.push({ + type: "error", + message: `UE "${ue.name}" : creation echouee`, + }); + continue; + } + const createdUE = await ueRes.json(); + added++; + details.push({ + type: "change", + message: `UE "${ue.name}" creee (id: ${createdUE.id})`, + }); + + for (const mod of ue.modules) { + const modRes = await fetch("/admin/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: mod.code, nom: mod.name }), + }); + if (modRes.ok) { + added++; + details.push({ + type: "change", + message: `ECUE ${mod.code} "${mod.name}" cree`, + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ + type: "error", + message: `ECUE "${mod.code}" : creation echouee`, + }); + continue; + } + + const linkRes = await fetch("/admin/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idModule: mod.code, + idUE: createdUE.id, + idPromo: promoId, + coeff: mod.coeff, + }), + }); + if (linkRes.ok) { + added++; + } else { + errCount++; + details.push({ + type: "error", + message: `Lien ${mod.code} -> UE ${ue.name} : echoue`, + }); + } + } + } + } + + importResult.value = { + added, + modified: 0, + ignored, + errors: errCount, + details, + }; + } catch { + error.value = "Erreur lors de l'import."; + } finally { + uploading.value = false; + } + } + + function downloadTemplate() { + globalThis.open("/templates/modele_maquette.xlsx", "_blank"); + } + + function _downloadExport() { + Promise.all([ + fetch("/admin/api/ues").then((r) => r.json()), + fetch("/admin/api/ue-modules").then((r) => r.json()), + fetch("/admin/api/modules").then((r) => r.json()), + ]).then(([uesData, ueModulesData, modulesData]) => { + const modMap = Object.fromEntries( + modulesData.map((m: { id: string; nom: string }) => [m.id, m]), + ); + + const data: (string | number | null)[][] = [ + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\nECTS", + "Coeff.", + ], + ]; + + for (const ue of uesData) { + const mods = ueModulesData.filter( + (um: { idUE: number }) => um.idUE === ue.id, + ); + const totalCoeff = mods.reduce( + (s: number, um: { coeff: number }) => s + um.coeff, + 0, + ); + data.push(["UE", null, ue.nom, null, totalCoeff]); + for (const um of mods) { + const mod = modMap[um.idModule]; + data.push([ + null, + um.idModule, + null, + mod ? mod.nom : um.idModule, + null, + um.coeff, + ]); + } + data.push([]); + } + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, "Maquette"); + const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); + const blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "export_maquette.xlsx"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + }); + } + + return ( +
+ { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + }} + /> + +
{ + e.preventDefault(); + dragging.value = true; + }} + onDragLeave={() => (dragging.value = false)} + onDrop={(e) => { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + }} + onClick={() => inputRef.current?.click()} + > + + {file.value ? {file.value.name} : ( + <> + + Glisser le fichier maquette .xlsx ici + + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + + {importResult.value && ( + (importResult.value = null)} + /> + )} + + {/* Create promo inline */} +
+ +
+ (newPromoId.value = (e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + (newPromoAnnee.value = (e.target as HTMLInputElement).value)} + style="min-width: 8rem" + /> + +
+
+ + {/* Preview grouped by year */} + {preview.value && preview.value.length > 0 && ( +
+ {preview.value.map((year) => { + const totalMods = year.ues.reduce( + (s, ue) => s + ue.modules.length, + 0, + ); + return ( +
+
+

+ {year.label} + + — {year.ues.length} UE, {totalMods} ECUEs + +

+ +
+ +
+ + + + + + + + + + + {year.ues.map((ue, i) => + ue.modules.length === 0 + ? ( + + + + + ) + : ue.modules.map((mod, j) => ( + + {j === 0 && ( + + )} + + + + + )) + )} + +
UEECUECodeCoeff
{ue.name} + Aucun ECUE +
+ {ue.name} + {ue.ects != null && ( + + ({ue.ects} ECTS) + + )} + {mod.name}{mod.code}{mod.coeff}
+
+
+ ); + })} +
+ )} + +
+ + + { + /* TODO: fix blob download in Fresh + + */ + } +
+ +

+ Format : fichier maquette FISE / FISA avec lignes UE + et ECUEs (colonnes code, nom, coefficient) +

+
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts new file mode 100644 index 0000000..762a5df --- /dev/null +++ b/routes/(apps)/admin/(_props)/props.ts @@ -0,0 +1,31 @@ +import { AppProperties } from "$root/defaults/interfaces.ts"; + +const properties: AppProperties = { + name: "Admin", + icon: "school", + pages: { + index: "Accueil", + users: "Utilisateurs", + roles: "Rôles", + permissions: "Permissions", + modules: "ECUEs", + enseignements: "Enseignements", + promotions: "Promotions", + ues: "UEs", + "import-maquette": "Import Maquette", + }, + adminOnly: [ + "users", + "roles", + "permissions", + "modules", + "enseignements", + "promotions", + "ues", + "import-maquette", + ], + employeeOnly: true, + hint: "PolyMPR ECUE", +}; + +export default properties; diff --git a/routes/(apps)/admin/[slug].tsx b/routes/(apps)/admin/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/admin/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts new file mode 100644 index 0000000..bae6a2c --- /dev/null +++ b/routes/(apps)/admin/api/enseignements.ts @@ -0,0 +1,89 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const _NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +const CONFLICT = () => + new Response( + JSON.stringify({ error: "Cet enseignement existe déjà." }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + +export const handler: Handlers = { + // GET /enseignements + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(JSON.stringify([]), { + headers: { "content-type": "application/json" }, + }); + } + const rows = await db.select().from(enseignements); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + + // #29 POST /enseignements + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + let body: { idProf: string; idModule: string; idPromo: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } + + if (!body.idProf || !body.idModule || !body.idPromo) { + return new Response(null, { status: 400 }); + } + + // Check if enseignement already exists + const existing = await db + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, body.idProf), + eq(enseignements.idModule, body.idModule), + eq(enseignements.idPromo, body.idPromo), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + return CONFLICT(); + } + + const [created] = await db + .insert(enseignements) + .values({ + idProf: body.idProf, + idModule: body.idModule, + idPromo: body.idPromo, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts new file mode 100644 index 0000000..27cc6e2 --- /dev/null +++ b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts @@ -0,0 +1,76 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // #30 GET /enseignements/{idProf}/{idModule}/{idPromo} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idProf = context.params.idProf; + const idModule = context.params.idModule; + const idPromo = context.params.idPromo; + + const enseignement = await db + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, idProf), + eq(enseignements.idModule, idModule), + eq(enseignements.idPromo, idPromo), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!enseignement) return NOT_FOUND(); + + return new Response(JSON.stringify(enseignement), { + headers: { "content-type": "application/json" }, + }); + }, + + // #31 DELETE /enseignements/{idProf}/{idModule}/{idPromo} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idProf = context.params.idProf; + const idModule = context.params.idModule; + const idPromo = context.params.idPromo; + + const [deleted] = await db + .delete(enseignements) + .where( + and( + eq(enseignements.idProf, idProf), + eq(enseignements.idModule, idModule), + eq(enseignements.idPromo, idPromo), + ), + ) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/admin/api/example.ts b/routes/(apps)/admin/api/example.ts new file mode 100644 index 0000000..9f04cd1 --- /dev/null +++ b/routes/(apps)/admin/api/example.ts @@ -0,0 +1,22 @@ +import { Handlers } from "$fresh/server.ts"; + +export const handler: Handlers = { + async POST(request, context) { + if (request.headers.get("content-type") != "application/json") { + return new Response(null, { + status: 400, + }); + } + + const responseBody = { + requestBody: await request.json(), + context, + }; + + return new Response(JSON.stringify(responseBody), { + headers: { + "content-type": "application/json", + }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts new file mode 100644 index 0000000..4519db3 --- /dev/null +++ b/routes/(apps)/admin/api/modules.ts @@ -0,0 +1,62 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { modules } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #23 GET /modules + async GET( + _request: Request, + _context: FreshContext, + ): Promise { + const rows = await db.select().from(modules); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + + // #24 POST /modules + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + let body: { id: string; nom: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } + + if (!body.id || !body.id.trim() || !body.nom || !body.nom.trim()) { + return new Response(null, { status: 400 }); + } + + const existing = await db + .select() + .from(modules) + .where(eq(modules.id, body.id)) + .then((rows) => rows[0] ?? null); + + if (existing) { + return new Response( + JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + } + + const [created] = await db + .insert(modules) + .values({ id: body.id, nom: body.nom }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts new file mode 100644 index 0000000..8c3f91f --- /dev/null +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -0,0 +1,93 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { + enseignements, + modules, + notes, + ueModules, +} from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +export const handler: Handlers = { + // #25 GET /modules/{idModule} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const module = await db + .select() + .from(modules) + .where(eq(modules.id, context.params.idModule)) + .then((rows) => rows[0] ?? null); + + if (!module) return NOT_FOUND(); + + return new Response(JSON.stringify(module), { + headers: { "content-type": "application/json" }, + }); + }, + + // #26 PUT /modules/{idModule} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + let body: { nom: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } + + if (typeof body.nom !== "string") { + return new Response(null, { status: 400 }); + } + + const [updated] = await db + .update(modules) + .set({ nom: body.nom }) + .where(eq(modules.id, context.params.idModule)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // #27 DELETE /modules/{idModule} + // Cascade: deletes notes, ue_modules, enseignements for this module. + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const idModule = context.params.idModule; + + const mod = await db + .select() + .from(modules) + .where(eq(modules.id, idModule)) + .then((r) => r[0] ?? null); + + if (!mod) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(notes).where(eq(notes.idModule, idModule)); + await tx.delete(ueModules).where(eq(ueModules.idModule, idModule)); + await tx.delete(enseignements).where( + eq(enseignements.idModule, idModule), + ); + await tx.delete(modules).where(eq(modules.id, idModule)); + }); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/admin/api/permissions.ts b/routes/(apps)/admin/api/permissions.ts new file mode 100644 index 0000000..61bf4ed --- /dev/null +++ b/routes/(apps)/admin/api/permissions.ts @@ -0,0 +1,16 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { permissions } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; + +export const handler: Handlers = { + async GET( + _request: Request, + _context: FreshContext, + ): Promise { + const result = await db.select().from(permissions); + return new Response(JSON.stringify(result), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/roles.ts b/routes/(apps)/admin/api/roles.ts new file mode 100644 index 0000000..15b328a --- /dev/null +++ b/routes/(apps)/admin/api/roles.ts @@ -0,0 +1,68 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { rolePermissions, roles } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +async function getRoleWithPermissions( + id: number, +): Promise<{ id: number; nom: string; permissions: string[] } | null> { + const role = await db + .select() + .from(roles) + .where(eq(roles.id, id)) + .then((rows) => rows[0] ?? null); + + if (!role) return null; + + const perms = await db + .select({ idPermission: rolePermissions.idPermission }) + .from(rolePermissions) + .where(eq(rolePermissions.idRole, id)); + + return { + id: role.id, + nom: role.nom, + permissions: perms.map((p) => p.idPermission), + }; +} + +export const handler: Handlers = { + // #65 GET /roles + async GET( + _request: Request, + _context: FreshContext, + ): Promise { + const allRoles = await db.select().from(roles); + + const result = await Promise.all( + allRoles.map((r) => getRoleWithPermissions(r.id)), + ); + + return new Response(JSON.stringify(result), { + headers: { "content-type": "application/json" }, + }); + }, + + // #66 POST /roles + async POST( + request: Request, + _context: FreshContext, + ): Promise { + const body: { nom: string } = await request.json(); + + if (!body.nom) { + return new Response(null, { status: 400 }); + } + + const [created] = await db + .insert(roles) + .values({ nom: body.nom }) + .returning(); + + return new Response( + JSON.stringify({ id: created.id, nom: created.nom, permissions: [] }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + }, +}; diff --git a/routes/(apps)/admin/api/roles/[idRole].ts b/routes/(apps)/admin/api/roles/[idRole].ts new file mode 100644 index 0000000..7b15c8c --- /dev/null +++ b/routes/(apps)/admin/api/roles/[idRole].ts @@ -0,0 +1,110 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { rolePermissions, roles, users } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +async function getRoleWithPermissions( + id: number, +): Promise<{ id: number; nom: string; permissions: string[] } | null> { + const role = await db + .select() + .from(roles) + .where(eq(roles.id, id)) + .then((rows) => rows[0] ?? null); + + if (!role) return null; + + const perms = await db + .select({ idPermission: rolePermissions.idPermission }) + .from(rolePermissions) + .where(eq(rolePermissions.idRole, id)); + + return { + id: role.id, + nom: role.nom, + permissions: perms.map((p) => p.idPermission), + }; +} + +export const handler: Handlers = { + // #67 GET /roles/{idRole} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const id = Number(context.params.idRole); + const role = await getRoleWithPermissions(id); + + if (!role) return NOT_FOUND(); + + return new Response(JSON.stringify(role), { + headers: { "content-type": "application/json" }, + }); + }, + + // #68 PUT /roles/{idRole} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + const id = Number(context.params.idRole); + const body: { nom: string; permissions: string[] } = await request.json(); + + const [updated] = await db + .update(roles) + .set({ nom: body.nom }) + .where(eq(roles.id, id)) + .returning(); + + if (!updated) return NOT_FOUND(); + + // Reset permissions + await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); + + if (body.permissions?.length) { + await db.insert(rolePermissions).values( + body.permissions.map((p) => ({ idRole: id, idPermission: p })), + ); + } + + const role = await getRoleWithPermissions(id); + return new Response(JSON.stringify(role), { + headers: { "content-type": "application/json" }, + }); + }, + + // #69 DELETE /roles/{idRole} + // Cascade: deletes role_permissions, detaches users (idRole set to null). + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const id = Number(context.params.idRole); + + const role = await db + .select() + .from(roles) + .where(eq(roles.id, id)) + .then((r) => r[0] ?? null); + + if (!role) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); + await tx + .update(users) + .set({ idRole: null }) + .where(eq(users.idRole, id)); + await tx.delete(roles).where(eq(roles.id, id)); + }); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/admin/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts new file mode 100644 index 0000000..d2672d4 --- /dev/null +++ b/routes/(apps)/admin/api/ue-modules.ts @@ -0,0 +1,72 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { ueModules } from "../../../../databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #37 GET /ue-modules + async GET(request) { + try { + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + const idUEParam = url.searchParams.get("idUE"); + + const idUE = idUEParam ? parseInt(idUEParam) : null; + + if (idUEParam && isNaN(idUE!)) { + return new Response("Paramètre idUE invalide", { status: 400 }); + } + + const result = await db.select().from(ueModules).where( + and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, + ), + ); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching UE-modules:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #38 POST /ue-modules + async POST(request) { + try { + const body = await request.json(); + const { idModule, idUE, idPromo, coeff } = body; + + if (!idModule || !idUE || !idPromo || coeff === undefined) { + return new Response( + "Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", + { status: 400 }, + ); + } + + if (typeof coeff !== "number" || coeff < 0) { + return new Response("Champ 'coeff' doit être un nombre >= 0", { + status: 400, + }); + } + + const result = await db.insert(ueModules).values({ + idModule, + idUE, + idPromo, + coeff, + }).returning(); + + return new Response(JSON.stringify(result[0]), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error creating UE-ECUE:", error); + return new Response("Failed to create UE-ECUE", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts new file mode 100644 index 0000000..b71396d --- /dev/null +++ b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -0,0 +1,141 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ueModules } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Association UE-ECUE introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +const BAD_REQUEST = () => + new Response( + JSON.stringify({ error: "Paramètres invalides" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + +export const handler: Handlers = { + // #39 GET /ue-modules/{idModule}/{idUE}/{idPromo} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idModule = context.params.idModule; + const idUE = Number(context.params.idUE); + const idPromo = context.params.idPromo; + + if (isNaN(idUE)) { + return BAD_REQUEST(); + } + + const ueModuleAssociation = await db + .select() + .from(ueModules) + .where( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), + ) + .then((rows) => rows[0] ?? null); + + if (!ueModuleAssociation) return NOT_FOUND(); + + return new Response(JSON.stringify(ueModuleAssociation), { + headers: { "content-type": "application/json" }, + }); + }, + + // #40 PUT /ue-modules/{idModule}/{idUE}/{idPromo} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idModule = context.params.idModule; + const idUE = Number(context.params.idUE); + const idPromo = context.params.idPromo; + + if (isNaN(idUE)) { + return BAD_REQUEST(); + } + + const body: { coeff: number } = await request.json(); + + if (typeof body.coeff !== "number") { + return new Response( + JSON.stringify({ error: "Le champ 'coeff' doit être un nombre" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(ueModules) + .set({ coeff: body.coeff }) + .where( + and( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), + ), + ) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response( + JSON.stringify({ + idModule: updated.idModule, + idUE: updated.idUE, + idPromo: updated.idPromo, + coeff: updated.coeff, + }), + { + headers: { "content-type": "application/json" }, + }, + ); + }, + + // #41 DELETE /ue-modules/{idModule}/{idUE}/{idPromo} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idModule = context.params.idModule; + const idUE = Number(context.params.idUE); + const idPromo = context.params.idPromo; + + if (isNaN(idUE)) { + return BAD_REQUEST(); + } + + const [deleted] = await db + .delete(ueModules) + .where( + and( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), + ), + ) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/admin/api/ues.ts b/routes/(apps)/admin/api/ues.ts new file mode 100644 index 0000000..92242da --- /dev/null +++ b/routes/(apps)/admin/api/ues.ts @@ -0,0 +1,42 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { ues } from "../../../../databases/schema.ts"; + +export const handler: Handlers = { + // #32 GET /ues + async GET() { + try { + const result = await db.select().from(ues); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching UEs:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #33 POST /ues + async POST(request) { + try { + const body = await request.json(); + const { nom } = body; + + if (!nom || !nom.trim()) { + return new Response("Champ 'nom' manquant", { status: 400 }); + } + + const result = await db.insert(ues).values({ nom }).returning(); + + return new Response(JSON.stringify(result[0]), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error creating UE:", error); + return new Response("Failed to create UE", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/admin/api/ues/[idUE].ts b/routes/(apps)/admin/api/ues/[idUE].ts new file mode 100644 index 0000000..92f6e1a --- /dev/null +++ b/routes/(apps)/admin/api/ues/[idUE].ts @@ -0,0 +1,133 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../../databases/db.ts"; +import { + ajustements, + ueModules, + ues, +} from "../../../../../databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // # 34 GET /ues/:idUE + async GET(_request, context) { + try { + const idUE = parseInt(context.params.idUE); + + if (isNaN(idUE)) { + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await db.select().from(ues).where(eq(ues.id, idUE)); + + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify(result[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching UE:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #35 PUT /ues/:idUE + async PUT(request, context) { + try { + const idUE = parseInt(context.params.idUE); + + if (isNaN(idUE)) { + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const body = await request.json(); + const { nom } = body; + + if (!nom) { + return new Response("Champ 'nom' manquant", { status: 400 }); + } + + const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE)) + .returning(); + + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify(result[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error updating UE:", error); + return new Response("Failed to update UE", { status: 500 }); + } + }, + + // #36 DELETE /ues/:idUE + // Cascade: deletes ajustements, ue_modules for this UE. + async DELETE(_request, context) { + try { + const idUE = parseInt(context.params.idUE); + + if (isNaN(idUE)) { + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const existing = await db.select().from(ues).where(eq(ues.id, idUE)); + + if (existing.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + await db.transaction(async (tx) => { + await tx.delete(ajustements).where(eq(ajustements.idUE, idUE)); + await tx.delete(ueModules).where(eq(ueModules.idUE, idUE)); + await tx.delete(ues).where(eq(ues.id, idUE)); + }); + + return new Response(null, { status: 204 }); + } catch (error) { + console.error("Error deleting UE:", error); + return new Response("Failed to delete UE", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/admin/api/users.ts b/routes/(apps)/admin/api/users.ts new file mode 100644 index 0000000..61317d7 --- /dev/null +++ b/routes/(apps)/admin/api/users.ts @@ -0,0 +1,74 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { users } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #60 GET /users + async GET( + request: Request, + _context: FreshContext, + ): Promise { + const url = new URL(request.url); + const idRole = url.searchParams.get("idRole"); + + const rows = idRole + ? await db.select().from(users).where(eq(users.idRole, Number(idRole))) + : await db.select().from(users); + + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + + // #61 POST /users + async POST( + request: Request, + _context: FreshContext, + ): Promise { + let body: { id: string; nom: string; prenom: string; idRole: number }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } + + if ( + !body.id || !body.id.trim() || !body.nom || !body.nom.trim() || + !body.prenom || !body.prenom.trim() + ) { + return new Response(null, { status: 400 }); + } + + const existing = await db + .select() + .from(users) + .where(eq(users.id, body.id)) + .then((rows) => rows[0] ?? null); + + if (existing) { + return new Response( + JSON.stringify({ + error: "Un utilisateur avec cet identifiant existe déjà", + }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + } + + const [created] = await db + .insert(users) + .values({ + id: body.id, + nom: body.nom, + prenom: body.prenom, + idRole: body.idRole, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/users/[id].ts b/routes/(apps)/admin/api/users/[id].ts new file mode 100644 index 0000000..ae064d0 --- /dev/null +++ b/routes/(apps)/admin/api/users/[id].ts @@ -0,0 +1,76 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { enseignements, users } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +export const handler: Handlers = { + // #62 GET /users/{id} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const user = await db + .select() + .from(users) + .where(eq(users.id, context.params.id)) + .then((rows) => rows[0] ?? null); + + if (!user) return NOT_FOUND(); + + return new Response(JSON.stringify(user), { + headers: { "content-type": "application/json" }, + }); + }, + + // #63 PUT /users/{id} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + const body: { nom: string; prenom: string; idRole: number } = await request + .json(); + + const [updated] = await db + .update(users) + .set({ nom: body.nom, prenom: body.prenom, idRole: body.idRole }) + .where(eq(users.id, context.params.id)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // #64 DELETE /users/{id} + // Cascade: deletes enseignements for this user. + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const id = context.params.id; + + const user = await db + .select() + .from(users) + .where(eq(users.id, id)) + .then((r) => r[0] ?? null); + + if (!user) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(enseignements).where(eq(enseignements.idProf, id)); + await tx.delete(users).where(eq(users.id, id)); + }); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/admin/index.tsx b/routes/(apps)/admin/index.tsx new file mode 100644 index 0000000..1d82f7f --- /dev/null +++ b/routes/(apps)/admin/index.tsx @@ -0,0 +1,2 @@ +import makeIndex from "$root/defaults/makeIndex.ts"; +export default makeIndex(import.meta.dirname!); diff --git a/routes/(apps)/admin/modules/[idModule].tsx b/routes/(apps)/admin/modules/[idModule].tsx new file mode 100644 index 0000000..858bfa9 --- /dev/null +++ b/routes/(apps)/admin/modules/[idModule].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditModule from "../(_islands)/EditModule.tsx"; + +// deno-lint-ignore require-await +export default async function EditModulePage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/admin/partials/enseignements.tsx b/routes/(apps)/admin/partials/enseignements.tsx new file mode 100644 index 0000000..ea91b69 --- /dev/null +++ b/routes/(apps)/admin/partials/enseignements.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminEnseignements from "../(_islands)/AdminEnseignements.tsx"; + +// deno-lint-ignore require-await +async function Enseignements( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Enseignements as Page }; +export const config = getPartialsConfig(); +export default makePartials(Enseignements); diff --git a/routes/(apps)/admin/partials/import-maquette.tsx b/routes/(apps)/admin/partials/import-maquette.tsx new file mode 100644 index 0000000..33bd0ba --- /dev/null +++ b/routes/(apps)/admin/partials/import-maquette.tsx @@ -0,0 +1,24 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import ImportMaquette from "../(_islands)/ImportMaquette.tsx"; + +// deno-lint-ignore require-await +async function ImportMaquettePage( + _request: Request, + _context: FreshContext, +) { + return ( +
+

Importer une Maquette (UE & Modules)

+ +
+ ); +} + +export { ImportMaquettePage as Page }; +export const config = getPartialsConfig(); +export default makePartials(ImportMaquettePage); diff --git a/routes/(apps)/admin/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx new file mode 100644 index 0000000..bedfc1e --- /dev/null +++ b/routes/(apps)/admin/partials/index.tsx @@ -0,0 +1,44 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; + +// deno-lint-ignore require-await +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Administration

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+

+ Gérez les{" "} + + modules + + ,{" "} + + utilisateurs + + ,{" "} + + rôles + {" "} + depuis la barre de navigation. +

+
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(Index); diff --git a/routes/(apps)/admin/partials/modules.tsx b/routes/(apps)/admin/partials/modules.tsx new file mode 100644 index 0000000..4b1b268 --- /dev/null +++ b/routes/(apps)/admin/partials/modules.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminModules from "../(_islands)/AdminModules.tsx"; + +// deno-lint-ignore require-await +async function Modules( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Modules as Page }; +export const config = getPartialsConfig(); +export default makePartials(Modules); diff --git a/routes/(apps)/admin/partials/permissions.tsx b/routes/(apps)/admin/partials/permissions.tsx new file mode 100644 index 0000000..9c9440e --- /dev/null +++ b/routes/(apps)/admin/partials/permissions.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminPermissions from "../(_islands)/AdminPermissions.tsx"; + +// deno-lint-ignore require-await +async function Permissions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Permissions as Page }; +export const config = getPartialsConfig(); +export default makePartials(Permissions); diff --git a/routes/(apps)/admin/partials/promotions.tsx b/routes/(apps)/admin/partials/promotions.tsx new file mode 100644 index 0000000..815c689 --- /dev/null +++ b/routes/(apps)/admin/partials/promotions.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminPromotions from "../(_islands)/AdminPromotions.tsx"; + +// deno-lint-ignore require-await +async function Promotions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Promotions as Page }; +export const config = getPartialsConfig(); +export default makePartials(Promotions); diff --git a/routes/(apps)/admin/partials/roles.tsx b/routes/(apps)/admin/partials/roles.tsx new file mode 100644 index 0000000..70eb3d7 --- /dev/null +++ b/routes/(apps)/admin/partials/roles.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminRoles from "../(_islands)/AdminRoles.tsx"; + +// deno-lint-ignore require-await +async function Roles( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Roles as Page }; +export const config = getPartialsConfig(); +export default makePartials(Roles); diff --git a/routes/(apps)/admin/partials/ues.tsx b/routes/(apps)/admin/partials/ues.tsx new file mode 100644 index 0000000..007c1d9 --- /dev/null +++ b/routes/(apps)/admin/partials/ues.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminUEs from "../(_islands)/AdminUEs.tsx"; + +// deno-lint-ignore require-await +async function UEs( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { UEs as Page }; +export const config = getPartialsConfig(); +export default makePartials(UEs); diff --git a/routes/(apps)/admin/partials/users.tsx b/routes/(apps)/admin/partials/users.tsx new file mode 100644 index 0000000..36383bd --- /dev/null +++ b/routes/(apps)/admin/partials/users.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminUsers from "../(_islands)/AdminUsers.tsx"; + +// deno-lint-ignore require-await +async function Users( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Users as Page }; +export const config = getPartialsConfig(); +export default makePartials(Users); diff --git a/routes/(apps)/admin/users/[id].tsx b/routes/(apps)/admin/users/[id].tsx new file mode 100644 index 0000000..868db34 --- /dev/null +++ b/routes/(apps)/admin/users/[id].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditUser from "../(_islands)/EditUser.tsx"; + +// deno-lint-ignore require-await +export default async function EditUserPage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx b/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx deleted file mode 100644 index 7befa63..0000000 --- a/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Promotion { - id: number; - name: string; -} - -interface Student { - id: string; - firstName: string; - lastName: string; - promotionId: number; -} - -interface Mobility { - id: number; - studentId: string; - startDate: string | null; - endDate: string | null; - weeksCount: number | null; - destinationCountry: string | null; - destinationName: string | null; - mobilityStatus: string; -} - -export default function ConsultMobility() { - const [data, setData] = useState< - | { - promotions?: Promotion[]; - students?: Student[]; - mobilities?: Mobility[]; - } - | null - >(null); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - console.log("ConsultMobility: Fetching data from API..."); - try { - const response = await fetch("/mobility/api/insert_mobility"); - console.log("ConsultMobility: API response status:", response.status); - - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("ConsultMobility: Data fetched successfully:", result); - setData(result); - } catch (err) { - console.error("ConsultMobility: Error fetching data:", err); - setError("Failed to load mobility data. Please try again later."); - } - }; - - fetchData(); - }, []); - - if (error) { - return

{error}

; - } - - if (!data?.promotions) { - return

No promotions found.

; - } - - return ( -
-

Consult Mobility

- {data.promotions.map((promo) => ( -
-

Promotion: {promo.name}

- - - - - - - - - - - - - - - - {data.students - ?.filter((student) => student.promotionId === promo.id) - .map((student) => { - const mobility = data.mobilities?.find((mob) => - mob.studentId === student.id - ); - return ( - - - - - - - - - - - - ); - })} - -
IDFirst NameLast NameStart DateEnd DateWeeks CountDestination CountryDestination NameStatus
{student.id}{student.firstName}{student.lastName}{mobility?.startDate || "N/A"}{mobility?.endDate || "N/A"}{mobility?.weeksCount ?? "N/A"}{mobility?.destinationCountry || "N/A"}{mobility?.destinationName || "N/A"}{mobility?.mobilityStatus || "N/A"}
-
- ))} -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx b/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx deleted file mode 100644 index 3e008bc..0000000 --- a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Promotion { - id: number; - name: string; -} - -interface Student { - id: number; - firstName: string; - lastName: string; - mail: string; - promotionId: number; - promotionName: string; -} - -export default function ConsultStudents_test() { - const [data, setData] = useState< - { promotions: Promotion[]; students: Student[] } | null - >(null); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch("/students/api/insert_students"); - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - setData(result); - } catch (err) { - console.error("Error fetching data:", err); - setError("Failed to load data. Please try again later."); - } - }; - - fetchData(); - }, []); - - return ( -
-

Consult Students

- {error &&

{error}

} - {data?.promotions.map((promo) => ( -
-

Promotion: {promo.id}

- - - - - - - - - - - {data.students - .filter((student) => student.promotionId === promo.id) - .map((student) => ( - - - - - - - ))} - -
IDFirst NameLast NameEmail
{student.id}{student.firstName}{student.lastName}{student.mail}
-
- ))} -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/EditMobility.tsx b/routes/(apps)/mobility/(_islands)/EditMobility.tsx deleted file mode 100644 index 8991926..0000000 --- a/routes/(apps)/mobility/(_islands)/EditMobility.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Student { - id: string; - firstName: string; - lastName: string; - promotionId: number; -} - -interface Promotion { - id: number; - name: string; -} - -interface Mobility { - id: number | null; - studentId: string; - startDate: string | null; - endDate: string | null; - weeksCount: number | null; - destinationCountry: string | null; - destinationName: string | null; - mobilityStatus: string; -} - -export default function EditMobility() { - const [data, setData] = useState< - | { - promotions?: Promotion[]; - students?: Student[]; - mobilities?: Mobility[]; - } - | null - >(null); - const [error, setError] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - const fetchData = async () => { - console.log("EditMobility: Fetching data from API..."); - try { - const response = await fetch("/mobility/api/insert_mobility"); - console.log("EditMobility: API response status:", response.status); - - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("EditMobility: Data fetched successfully:", result); - setData(result); - } catch (err) { - console.error("EditMobility: Error fetching data:", err); - setError("Failed to load mobility data. Please try again later."); - } - }; - - fetchData(); - }, []); - - const handleChange = ( - studentId: string, - field: keyof Mobility, - value: string | number | null, - ) => { - if (!data) return; - - setData((prevData) => { - if (!prevData) return null; - - const updatedMobilities = prevData.mobilities?.map((mobility) => { - if (mobility.studentId === studentId) { - const updatedMobility = { ...mobility, [field]: value }; - - if (field === "startDate" || field === "endDate") { - const startDate = new Date(updatedMobility.startDate || ""); - const endDate = new Date(updatedMobility.endDate || ""); - if (startDate && endDate && startDate <= endDate) { - const weeks = Math.ceil( - (endDate.getTime() - startDate.getTime()) / - (7 * 24 * 60 * 60 * 1000), - ); - updatedMobility.weeksCount = weeks; - } else { - updatedMobility.weeksCount = null; - } - } - - return updatedMobility; - } - return mobility; - }) || []; - - return { ...prevData, mobilities: updatedMobilities }; - }); - }; - - const handleSave = async () => { - setIsSaving(true); - - try { - const response = await fetch("/mobility/api/insert_mobility", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: data?.mobilities }), - }); - - console.log("EditMobility: Save response status:", response.status); - - if (response.ok) { - alert("Data saved successfully!"); - globalThis.location.reload(); - } else { - throw new Error(`Failed to save data: ${response.statusText}`); - } - } catch (error) { - console.error("EditMobility: Error saving data:", error); - alert("An error occurred while saving data."); - } finally { - setIsSaving(false); - } - }; - - if (error) { - return

{error}

; - } - - if (!data?.promotions) { - return

Loading data...

; - } - - return ( -
-

Edit Mobility

- {data.promotions.map((promo) => ( -
-

Promotion: {promo.name}

- - - - - - - - - - - - - - - - {data.students - ?.filter((student) => student.promotionId === promo.id) - .map((student) => { - const mobility = data.mobilities?.find((mob) => - mob.studentId === student.id - ) || { - id: null, - studentId: student.id, - startDate: null, - endDate: null, - weeksCount: null, - destinationCountry: null, - destinationName: null, - mobilityStatus: "N/A", - }; - - return ( - - - - - - - - - - - - ); - })} - -
IDFirst NameLast NameStart DateEnd DateWeeks CountDestination CountryDestination NameStatus
{student.id}{student.firstName}{student.lastName} - - handleChange( - student.id, - "startDate", - e.target.value, - )} - /> - - - handleChange(student.id, "endDate", e.target.value)} - /> - {mobility.weeksCount ?? "N/A"} - - handleChange( - student.id, - "destinationCountry", - e.target.value, - )} - /> - - - handleChange( - student.id, - "destinationName", - e.target.value, - )} - /> - - -
-
- ))} - -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx new file mode 100644 index 0000000..a167414 --- /dev/null +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -0,0 +1,997 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Mobilite = { + id: number; + numEtud: number; + duree: number; + contratMob: string | null; + ecole: string | null; + pays: string | null; + status: string; + idStage: number | null; +}; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 12; + +const STATUS_ORDER = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +const STATUS_LABELS: Record = { + contracts_received: "Contrats reçus", + under_revision: "En révision", + done: "Signé", + validated: "Validé", + canceled: "Annulé", +}; + +const STATUS_COLORS: Record = { + contracts_received: "#f5a623", + under_revision: "#dc2626", + done: "#22c55e", + validated: "light-dark(var(--light-accent-color), var(--dark-accent-color))", + canceled: + "light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))", +}; + +function lowestStatus(mobs: Mobilite[]): string { + let lowest = STATUS_ORDER.length - 1; + for (const m of mobs) { + const idx = STATUS_ORDER.indexOf(m.status as typeof STATUS_ORDER[number]); + if (idx >= 0 && idx < lowest) lowest = idx; + } + return STATUS_ORDER[lowest]; +} + +function validatedWeeks(mobs: Mobilite[]): number { + return mobs + .filter((m) => m.status === "validated") + .reduce((sum, m) => sum + m.duree, 0); +} + +export default function MobilityOverview( + { initialNumEtud }: { initialNumEtud?: number } = {}, +) { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [mobilites, setMobilites] = useState([]); + const [stagesMap, setStagesMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [tab, setTab] = useState<"liste" | "kanban">("liste"); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingMob, setEditingMob] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, mRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/mobility/api/mobilites"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, mData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + mRes.ok ? mRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setMobilites(mData); + setStagesMap( + Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])), + ); + if (initialNumEtud) { + const s = (sData as Student[]).find((s) => + s.numEtud === initialNumEtud + ); + if (s) setDetailStudent(s); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + function openStudent(s: Student) { + setDetailStudent(s); + history.pushState(null, "", `/mobility/overview/${s.numEtud}`); + } + + function closeStudent() { + setDetailStudent(null); + setEditingMob(null); + setShowAddForm(false); + history.pushState(null, "", "/mobility/overview"); + } + + // If in detail view, render that + if (detailStudent) { + return ( + m.numEtud === detailStudent.numEtud)} + allMobilites={mobilites} + stagesMap={stagesMap} + editingMob={editingMob} + setEditingMob={setEditingMob} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={closeStudent} + onReload={load} + /> + ); + } + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + if (error) { + return ( +
+

{error}

+
+ ); + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + + const mobsByStudent = (numEtud: number) => + mobilites.filter((m) => m.numEtud === numEtud); + + return ( +
+

Suivi des mobilités

+ +
+ + +
+ +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {tab === "liste" + ? ( + openStudent(s)} + /> + ) + : ( + openStudent(s)} + /> + )} +
+ ); +} + +// ─── Liste View ───────────────────────────────────────────── + +function ListView( + { students, mobsByStudent, onConsult }: { + students: Student[]; + mobsByStudent: (n: number) => Mobilite[]; + onConsult: (s: Student) => void; + }, +) { + return ( +
+ + + + + + + + + + + + {students.length === 0 + ? ( + + + + ) + : students.map((s) => { + const mobs = mobsByStudent(s.numEtud); + const weeks = validatedWeeks(mobs); + const ok = weeks >= REQUIRED_WEEKS; + return ( + + + + + + + + ); + })} + +
N° étud.NomPrénomSemainesActions
Aucun élève trouvé
{s.numEtud}{s.nom}{s.prenom} + + {weeks}/{REQUIRED_WEEKS} + + + +
+
+ ); +} + +// ─── Kanban View ──────────────────────────────────────────── + +function KanbanView( + { students, mobsByStudent, onConsult }: { + students: Student[]; + mobsByStudent: (n: number) => Mobilite[]; + onConsult: (s: Student) => void; + }, +) { + // Students who have at least one mobility + const studentsWithMobs = students.filter( + (s) => mobsByStudent(s.numEtud).length > 0, + ); + + // Group students by their lowest status + const columns: Record = {}; + for (const status of STATUS_ORDER) columns[status] = []; + + for (const s of studentsWithMobs) { + const mobs = mobsByStudent(s.numEtud); + // Filter out canceled for lowest-status calc (canceled is separate) + const activeMobs = mobs.filter((m) => m.status !== "canceled"); + if (activeMobs.length === 0) { + // All canceled + columns["canceled"].push(s); + } else { + const lowest = lowestStatus(activeMobs); + columns[lowest].push(s); + } + } + + return ( +
+ {STATUS_ORDER.map((status) => ( +
+
+ + + {STATUS_LABELS[status]} + + + {columns[status].length} + +
+
+ {columns[status].length === 0 + ? ( +

+ Aucun +

+ ) + : columns[status].map((s) => { + const mobs = mobsByStudent(s.numEtud); + const weeks = validatedWeeks(mobs); + return ( +
onConsult(s)} + > +
+ {s.nom} {s.prenom} +
+
+ {s.numEtud} + = REQUIRED_WEEKS + ? "#22c55e" + : "#dc2626", + fontWeight: "var(--font-weight-bold)", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} sem. + +
+
+ ); + })} +
+
+ ))} +
+ ); +} + +// ─── Detail View ──────────────────────────────────────────── + +function DetailView( + { + student, + mobilites, + allMobilites, + stagesMap, + editingMob, + setEditingMob, + showAddForm, + setShowAddForm, + onBack, + onReload, + }: { + student: Student; + mobilites: Mobilite[]; + allMobilites: Mobilite[]; + stagesMap: Record; + editingMob: Mobilite | null; + setEditingMob: (m: Mobilite | null) => void; + showAddForm: boolean; + setShowAddForm: (v: boolean) => void; + onBack: () => void; + onReload: () => Promise; + }, +) { + const weeks = validatedWeeks(mobilites); + const ecoles = [...new Set(allMobilites.map((m) => m.ecole).filter(Boolean))]; + const paysList = [ + ...new Set(allMobilites.map((m) => m.pays).filter(Boolean)), + ]; + + async function deleteMob(id: number) { + if (!confirm("Supprimer cette mobilité ?")) return; + await fetch(`/mobility/api/mobilites/${id}`, { method: "DELETE" }); + await onReload(); + } + + return ( +
+ +

+ Consulter : {student.prenom} {student.nom} + = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} semaines validées + +

+ + {mobilites.length === 0 && ( +

Aucune mobilité enregistrée.

+ )} + + {mobilites.map((mob, i) => { + const stage = mob.idStage ? stagesMap[mob.idStage] : null; + const isEditing = editingMob?.id === mob.id; + + if (isEditing) { + return ( + setEditingMob(null)} + onSave={async () => { + setEditingMob(null); + await onReload(); + }} + /> + ); + } + + return ( +
+
+

+ Mobilité {i + 1} + {stage ? " : Stage" : " : Étude"} +

+

+ + {STATUS_LABELS[mob.status] ?? mob.status} + + Durée : {mob.duree} semaine(s) +

+
+
+ {stage + ? ( +

+ Entreprise : {stage.nomEntreprise} + {stage.mission && — {stage.mission}} +

+ ) + : ( +

+ {mob.ecole && ( + <> + École : {mob.ecole} + + )} + {mob.ecole && mob.pays && ,} + {mob.pays && ( + <> + Pays : {mob.pays} + + )} +

+ )} +
+ {mob.contratMob && ( + + Télécharger contrat + + )} + {!mob.idStage && ( + + )} + + +
+
+
+ ); + })} + + {showAddForm + ? ( + s.numEtud === student.numEtud) + .filter((s) => !mobilites.some((m) => m.idStage === s.id))} + onCancel={() => setShowAddForm(false)} + onSave={async () => { + setShowAddForm(false); + await onReload(); + }} + /> + ) + : ( + + )} +
+ ); +} + +// ─── Inline forms ─────────────────────────────────────────── + +function MobEditForm( + { mob, ecoles, paysList, onCancel, onSave }: { + mob: Mobilite; + ecoles: string[]; + paysList: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState(String(mob.duree)); + const [ecole, setEcole] = useState(mob.ecole ?? ""); + const [pays, setPays] = useState(mob.pays ?? ""); + const [status, setStatus] = useState(mob.status); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch(`/mobility/api/mobilites/${mob.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + duree: parseInt(duree), + ecole: ecole || null, + pays: pays || null, + status, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la modification"); + } finally { + setBusy(false); + } + } + + return ( +
+

Modifier la mobilité #{mob.id}

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+ {!mob.idStage && ( + <> +
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+ + )} +
+
+ + +
+
+ ); +} + +function MobAddForm( + { numEtud, ecoles, paysList, availableStages, onCancel, onSave }: { + numEtud: number; + ecoles: string[]; + paysList: string[]; + availableStages: Stage[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState("4"); + const [ecole, setEcole] = useState(""); + const [pays, setPays] = useState(""); + const [status, setStatus] = useState("contracts_received"); + const [selectedStageId, setSelectedStageId] = useState(""); + const [busy, setBusy] = useState(false); + + const isStageLinked = selectedStageId !== ""; + + function onStageChange(value: string) { + setSelectedStageId(value); + if (value) { + const stage = availableStages.find((s) => s.id === Number(value)); + if (stage) setDuree(String(stage.duree)); + } + } + + async function submit() { + setBusy(true); + try { + const res = await fetch("/mobility/api/mobilites", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + numEtud, + duree: parseInt(duree), + ecole: isStageLinked ? null : (ecole || null), + pays: isStageLinked ? null : (pays || null), + status: isStageLinked ? "validated" : status, + idStage: isStageLinked ? Number(selectedStageId) : null, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la création"); + } finally { + setBusy(false); + } + } + + return ( +
+

Nouvelle mobilité

+
+ {availableStages.length > 0 && ( +
+ + +
+ )} +
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+ {!isStageLinked && ( + <> +
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+ + )} +
+ {isStageLinked && ( +

+ Mobilité liée à un stage — status automatiquement « Validé » +

+ )} +
+ + +
+
+ ); +} + +function UploadContratBtn( + { mobId, hasContrat, onDone }: { + mobId: number; + hasContrat: boolean; + onDone: () => Promise; + }, +) { + const [busy, setBusy] = useState(false); + + function upload() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/pdf"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + setBusy(true); + try { + const fd = new FormData(); + fd.append("contrat", file); + const res = await fetch(`/mobility/api/mobilites/${mobId}/contrat`, { + method: "POST", + body: fd, + }); + if (!res.ok) throw new Error("Erreur upload"); + await onDone(); + } catch { + alert("Erreur lors de l'upload du contrat"); + } finally { + setBusy(false); + } + }; + input.click(); + } + + return ( + + ); +} diff --git a/routes/(apps)/mobility/(_props)/props.ts b/routes/(apps)/mobility/(_props)/props.ts index 75a91b4..37e9c17 100644 --- a/routes/(apps)/mobility/(_props)/props.ts +++ b/routes/(apps)/mobility/(_props)/props.ts @@ -3,14 +3,15 @@ import { AppProperties } from "$root/defaults/interfaces.ts"; const properties: AppProperties = { name: "PolyMobility", icon: "flight_takeoff", - hint: "Student mobility management", + hint: "Suivi des mobilités internationales", pages: { - index: "Homepage", - overview: "Mobility overview", - edit_mobility: "Mobility management", - consult_students_test: "Test consult students", + index: "Accueil", + overview: "Suivi des mobilités", + // "my-mobility": "Ma mobilité", // TODO Fix ma mobilité page, so it renders correctly for students }, - adminOnly: ["edit_mobility", "consult_students_test"], + adminOnly: ["overview"], + studentOnly: ["my-mobility"], + employeeOnly: true, // TODO Fix ma mobilité page, so it renders correctly for students }; export default properties; diff --git a/routes/(apps)/mobility/[...slug].tsx b/routes/(apps)/mobility/[...slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/mobility/[...slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/mobility/api/insert_mobility.ts b/routes/(apps)/mobility/api/insert_mobility.ts deleted file mode 100644 index 90f228f..0000000 --- a/routes/(apps)/mobility/api/insert_mobility.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Handlers } from "$fresh/server.ts"; -import { Database } from "@db/sqlite"; - -export const handler: Handlers = { - // deno-lint-ignore require-await - async GET() { - try { - console.log("Connecting to mobility database..."); - const connection = new Database("databases/data/mobility.db", { - create: false, - }); - connection.run( - "ATTACH DATABASE 'databases/data/students.db' AS students", - ); - console.log("Connected to databases."); - - const students = connection.prepare( - `SELECT - students.userId AS id, - students.firstName, - students.lastName, - students.promotionId AS promotionId, - promotions.name AS promotionName - FROM students.students - LEFT JOIN students.promotions ON students.promotionId = promotions.id`, - ).all(); - - const mobilities = connection.prepare( - `SELECT - mobility.id, - mobility.studentId, - mobility.startDate, - mobility.endDate, - mobility.weeksCount, - mobility.destinationCountry, - mobility.destinationName, - mobility.mobilityStatus - FROM mobility`, - ).all(); - - const promotions = connection.prepare( - `SELECT id, name FROM students.promotions`, - ).all(); - - connection.close(); - - return new Response( - JSON.stringify({ mobilities, students, promotions }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - console.error("Error fetching mobility data:", error); - return new Response("Failed to fetch data", { status: 500 }); - } - }, - - async POST(request) { - console.log("API /mobility/api/insert_mobility POST called"); - - try { - const body = await request.json(); - const { data } = body; - - if (!Array.isArray(data)) { - throw new Error("Invalid request body"); - } - console.log("Connecting to mobility database..."); - const connection = new Database("databases/data/mobility.db", { - create: false, - }); - - console.log("Attaching students database..."); - connection.run( - "ATTACH DATABASE 'databases/data/students.db' AS students", - ); - console.log("Students database attached successfully."); - - const insertQuery = connection.prepare( - `INSERT INTO mobility ( - id, studentId, startDate, endDate, weeksCount, destinationCountry, destinationName, mobilityStatus - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - startDate = excluded.startDate, - endDate = excluded.endDate, - weeksCount = excluded.weeksCount, - destinationCountry = excluded.destinationCountry, - destinationName = excluded.destinationName, - mobilityStatus = excluded.mobilityStatus`, - ); - - for (const mobility of data) { - const { - id, - studentId, - startDate, - endDate, - weeksCount, - destinationCountry, - destinationName, - mobilityStatus = "N/A", - } = mobility; - - console.log("Processing mobility data:", mobility); - - const studentExists = connection - .prepare( - `SELECT COUNT(*) AS count FROM students.students WHERE userId = ?`, - ) - .get(studentId); - - console.log(`Student ${studentId} exists:`, studentExists.count > 0); - - if (studentExists.count === 0) { - console.warn(`Skipping mobility for unknown studentId: ${studentId}`); - continue; - } - - let calculatedWeeksCount = weeksCount; - if (startDate && endDate) { - const start = new Date(startDate); - const end = new Date(endDate); - if (start <= end) { - calculatedWeeksCount = Math.ceil( - (end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000), - ); - } else { - calculatedWeeksCount = null; - } - } - - console.log("Executing SQL insert/update query for:", { - id, - studentId, - startDate, - endDate, - calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - }); - - insertQuery.run( - id, - studentId, - startDate, - endDate, - calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - ); - } - - connection.close(); - console.log("Mobility data inserted/updated successfully."); - return new Response("Data inserted/updated successfully", { - status: 200, - }); - } catch (error) { - console.error("Error inserting mobility data:", error); - return new Response("Failed to insert/update data", { status: 500 }); - } - }, -}; diff --git a/routes/(apps)/mobility/api/mobilites.ts b/routes/(apps)/mobility/api/mobilites.ts new file mode 100644 index 0000000..8485a07 --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites.ts @@ -0,0 +1,116 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const VALID_STATUSES = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +export const handler: Handlers = { + // GET /mobilites — list all, optional ?numEtud filter + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + + let query = db.select().from(mobilites).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(mobilites.numEtud, numEtud)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching mobilites:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // POST /mobilites — create mobility + async POST( + request: Request, + context: FreshContext, + ): Promise { + const isEmployee = + context.state.session.eduPersonPrimaryAffiliation === "employee"; + + try { + const body = await request.json(); + const { numEtud, duree, ecole, pays, status, idStage } = body; + + // Students can only create mobilites for themselves + if (!isEmployee && numEtud !== undefined) { + // Students cannot set idStage or status + if (idStage || (status && status !== "contracts_received")) { + return new Response(null, { status: 403 }); + } + } + + if (!numEtud || duree === undefined) { + return new Response( + JSON.stringify({ error: "Champs requis: numEtud, duree" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (!Number.isInteger(duree) || duree < 1) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if ( + status !== undefined && + !VALID_STATUSES.includes(status) + ) { + return new Response( + JSON.stringify({ + error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + // Stage-linked mobilities are always validated + const effectiveStatus = idStage + ? "validated" + : (status ?? "contracts_received"); + + const [created] = await db + .insert(mobilites) + .values({ + numEtud, + duree, + ecole: ecole ?? null, + pays: pays ?? null, + status: effectiveStatus, + idStage: idStage ?? null, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + console.error("Error creating mobilite:", error); + return new Response("Failed to create mobilite", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/mobility/api/mobilites/[idMob].ts b/routes/(apps)/mobility/api/mobilites/[idMob].ts new file mode 100644 index 0000000..e774c3c --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites/[idMob].ts @@ -0,0 +1,149 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const VALID_STATUSES = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // GET /mobilites/:idMob + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select() + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) return NOT_FOUND(); + + return new Response(JSON.stringify(row), { + headers: { "content-type": "application/json" }, + }); + }, + + // PUT /mobilites/:idMob (employee only) + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const body = await request.json(); + const { duree, ecole, pays, status, idStage } = body; + + if ( + status !== undefined && + !VALID_STATUSES.includes(status) + ) { + return new Response( + JSON.stringify({ + error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: Record = {}; + if (duree !== undefined) set.duree = duree; + if (ecole !== undefined) set.ecole = ecole; + if (pays !== undefined) set.pays = pays; + if (status !== undefined) set.status = status; + if (idStage !== undefined) { + set.idStage = idStage; + if (idStage) set.status = "validated"; + } + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ à modifier requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(mobilites) + .set(set) + .where(eq(mobilites.id, idMob)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /mobilites/:idMob (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + // Delete contract file if exists + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (row?.contratMob) { + try { + await Deno.remove(`uploads/contracts/${row.contratMob}`); + } catch { /* file may not exist */ } + } + + const [deleted] = await db + .delete(mobilites) + .where(eq(mobilites.id, idMob)) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts b/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts new file mode 100644 index 0000000..391c2ef --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts @@ -0,0 +1,156 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const CONTRACTS_DIR = "uploads/contracts"; + +export const handler: Handlers = { + // GET /mobilites/:idMob/contrat — download contract PDF + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + if (!row.contratMob) { + return new Response( + JSON.stringify({ error: "Aucun contrat pour cette mobilité" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + try { + const file = await Deno.readFile(`${CONTRACTS_DIR}/${row.contratMob}`); + return new Response(file, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${row.contratMob}"`, + }, + }); + } catch { + return new Response( + JSON.stringify({ error: "Fichier contrat introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + }, + + // POST /mobilites/:idMob/contrat — upload contract PDF + async POST( + request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + // Check mobility exists + const row = await db + .select({ id: mobilites.id }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + const formData = await request.formData(); + const file = formData.get("contrat"); + + if (!file || !(file instanceof File)) { + return new Response( + JSON.stringify({ error: "Fichier 'contrat' requis (PDF)" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (file.type !== "application/pdf") { + return new Response( + JSON.stringify({ error: "Le fichier doit être un PDF" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const filename = `mob_${idMob}.pdf`; + await Deno.mkdir(CONTRACTS_DIR, { recursive: true }); + await Deno.writeFile( + `${CONTRACTS_DIR}/${filename}`, + new Uint8Array(await file.arrayBuffer()), + ); + + const [updated] = await db + .update(mobilites) + .set({ contratMob: filename }) + .where(eq(mobilites.id, idMob)) + .returning(); + + return new Response(JSON.stringify(updated), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /mobilites/:idMob/contrat — remove contract (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + if (row.contratMob) { + try { + await Deno.remove(`${CONTRACTS_DIR}/${row.contratMob}`); + } catch { /* file may not exist */ } + } + + await db + .update(mobilites) + .set({ contratMob: null }) + .where(eq(mobilites.id, idMob)); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx b/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx deleted file mode 100644 index 8727e5d..0000000 --- a/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ConsultStudents_test from "$root/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx"; -import { - getPartialsConfig, - makePartials, -} from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -//import EditStudents from "../(_islands)/EditStudents.tsx"; - -// deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Test consult students

- - - ); -} - -export const config = getPartialsConfig(); -export default makePartials(Mobility); diff --git a/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx b/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx deleted file mode 100644 index 88e75bf..0000000 --- a/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import EditMobility from "$root/routes/(apps)/mobility/(_islands)/EditMobility.tsx"; -import { - getPartialsConfig, - makePartials, -} from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; - -// deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Edit mobility

- - - ); -} - -export const config = getPartialsConfig(); -export default makePartials(Mobility); diff --git a/routes/(apps)/mobility/partials/index.tsx b/routes/(apps)/mobility/partials/index.tsx index 2971e0e..0d813f5 100644 --- a/routes/(apps)/mobility/partials/index.tsx +++ b/routes/(apps)/mobility/partials/index.tsx @@ -3,11 +3,27 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Mobilité internationale

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+

Suivi des mobilités : 12 semaines validées requises par élève.

+
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/mobility/partials/overview.tsx b/routes/(apps)/mobility/partials/overview.tsx index c8775a6..21da0a4 100644 --- a/routes/(apps)/mobility/partials/overview.tsx +++ b/routes/(apps)/mobility/partials/overview.tsx @@ -1,20 +1,19 @@ -import ConsultMobility from "$root/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; import { getPartialsConfig, makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import MobilityOverview from "../(_islands)/MobilityOverview.tsx"; // deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Edit mobility

- - - ); +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; } +export { Overview as Page }; export const config = getPartialsConfig(); -export default makePartials(Mobility); +export default makePartials(Overview); diff --git a/routes/(apps)/mobility/partials/overview/[numEtud].tsx b/routes/(apps)/mobility/partials/overview/[numEtud].tsx new file mode 100644 index 0000000..2b3da03 --- /dev/null +++ b/routes/(apps)/mobility/partials/overview/[numEtud].tsx @@ -0,0 +1,20 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import MobilityOverview from "../../(_islands)/MobilityOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx new file mode 100644 index 0000000..dd4abae --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string | null }; + +export default function AdminConsultNotes() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + const [filterPrenom, setFilterPrenom] = useState(""); + const [applied, setApplied] = useState({ + promo: "", + nom: "", + prenom: "", + }); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les étudiants"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + const filtered = students.filter((s) => { + if (applied.promo && s.idPromo !== applied.promo) return false; + if ( + applied.nom && + !s.nom.toLowerCase().includes(applied.nom.toLowerCase()) + ) return false; + if ( + applied.prenom && + !s.prenom.toLowerCase().includes(applied.prenom.toLowerCase()) + ) return false; + return true; + }); + + function applyFilters() { + setApplied({ promo: filterPromo, nom: filterNom, prenom: filterPrenom }); + } + + return ( +
+
+

Consulter les Notes

+
+ + {error &&

{error}

} + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> + setFilterPrenom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
PromoNomPrénomN° ÉtudiantAction
+ Aucun étudiant trouvé +
{s.idPromo}{s.nom}{s.prenom}{s.numEtud} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx new file mode 100644 index 0000000..1855520 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -0,0 +1,626 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; +import { useEffect, useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; + +type Student = { numEtud: number; nom: string; prenom: string }; +type ColumnInfo = { + index: number; + code: string; + name: string; + coeff: number | null; + type: "module" | "malus" | "ue" | "semester" | "unknown"; +}; + +function parseHeader(header: string): { code: string; name: string } { + const parts = header.split(" - "); + if (parts.length >= 2) { + return { code: parts[0].trim(), name: parts.slice(1).join(" - ").trim() }; + } + return { code: header.trim(), name: header.trim() }; +} + +function detectColumnType( + header: string, + _coeff: number | null, +): ColumnInfo["type"] { + const h = header.trim(); + if (/^MALUS/i.test(h)) return "malus"; + if (/^S\d+$/i.test(h)) return "semester"; + // UE codes typically contain "U" followed by digits (e.g., JIN5U05, JTR5U01) + const { code } = parseHeader(h); + if (/U\d/i.test(code) && !/^MALUS/i.test(code)) return "ue"; + return "module"; +} + +export default function ImportNotes() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const importResult = useSignal(null); + const inputRef = useRef(null); + const students = useSignal([]); + const columns = useSignal([]); + const sheetNames = useSignal([]); + const selectedSheet = useSignal(""); + const session = useSignal<"1" | "2">("1"); + const workbookRef = useRef(null); + + useEffect(() => { + fetch("/students/api/students") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => (students.value = data)); + }, []); + + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; + return; + } + file.value = f; + error.value = null; + importResult.value = null; + columns.value = []; + + f.arrayBuffer().then((buf) => { + try { + const wb = XLSX.read(buf, { type: "array" }); + workbookRef.current = wb; + sheetNames.value = wb.SheetNames; + if (wb.SheetNames.length > 0) { + selectedSheet.value = wb.SheetNames[0]; + parseSheet(wb, wb.SheetNames[0]); + } + } catch { + error.value = "Impossible de lire le fichier."; + } + }); + } + + function parseSheet(wb: XLSX.WorkBook, sheetName: string) { + const sheet = wb.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + if (rows.length < 2) { + columns.value = []; + return; + } + + const headerRow = rows[0]; + const coeffRow = rows[1]; + + const cols: ColumnInfo[] = []; + // First 2 columns are nom/prenom, skip them + for (let i = 2; i < headerRow.length; i++) { + const h = headerRow[i]; + if (h == null || String(h).trim() === "") continue; + const header = String(h).trim(); + const coeff = typeof coeffRow[i] === "number" ? coeffRow[i] : null; + const { code, name } = parseHeader(header); + const type = detectColumnType(header, coeff as number | null); + cols.push({ index: i, code, name, coeff: coeff as number | null, type }); + } + columns.value = cols; + } + + function onSheetChange(name: string) { + selectedSheet.value = name; + if (workbookRef.current) { + parseSheet(workbookRef.current, name); + } + } + + function findStudent( + nom: string, + prenom: string, + ): Student | undefined { + const normNom = nom.toUpperCase().trim(); + const normPrenom = prenom.toUpperCase().trim(); + return students.value.find( + (s) => + s.nom.toUpperCase().trim() === normNom && + s.prenom.toUpperCase().trim() === normPrenom, + ); + } + + async function doImport() { + if (!workbookRef.current || !selectedSheet.value) return; + uploading.value = true; + error.value = null; + importResult.value = null; + + try { + const sheet = workbookRef.current.Sheets[selectedSheet.value]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + + const moduleCols = columns.value.filter((c) => c.type === "module"); + + let added = 0; + let modified = 0; + let ignored = 0; + let errors = 0; + const details: ImportDetail[] = []; + + // Process data rows (skip header + coeff rows) + for (let r = 2; r < rows.length; r++) { + const row = rows[r]; + if (!row || row.length < 3) continue; + + const nom = row[0] != null ? String(row[0]).trim() : ""; + const prenom = row[1] != null ? String(row[1]).trim() : ""; + if (!nom || !prenom) continue; + + const student = findStudent(nom, prenom); + if (!student) { + ignored++; + details.push({ + type: "error", + message: `${nom} ${prenom} : Etudiant non trouve`, + }); + continue; + } + + // Import module notes + for (const col of moduleCols) { + const val = row[col.index]; + if (val == null || typeof val !== "number") { + if (val != null && typeof val !== "number") { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Note "${val}" invalide`, + }); + } + continue; + } + if (val < 0 || val > 20) { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Note ${val} hors limites`, + }); + continue; + } + + const noteField = session.value === "2" ? "noteSession2" : "note"; + + // Try PUT first (update), then POST (create) + const putRes = await fetch( + `/notes/api/notes/${student.numEtud}/${ + encodeURIComponent(col.code) + }`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ [noteField]: val }), + }, + ); + + if (putRes.ok) { + const prev = await putRes.json(); + const oldVal = session.value === "2" + ? prev.noteSession2 + : prev.note; + modified++; + details.push({ + type: "change", + message: `${student.numEtud} : ${col.code} : ${ + oldVal ?? "null" + } -> ${val}`, + }); + } else if (putRes.status === 404) { + // Note doesn't exist yet, create it + const body: Record = { + numEtud: student.numEtud, + idModule: col.code, + note: session.value === "1" ? val : 0, + }; + if (session.value === "2") body.noteSession2 = val; + + const postRes = await fetch("/notes/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (postRes.ok) { + added++; + details.push({ + type: "change", + message: `${student.numEtud} : ${col.code} : null -> ${val}`, + }); + } else { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Matiere non trouvee`, + }); + } + } else { + errors++; + details.push({ + type: "error", + message: `${student.numEtud} : ${col.code} : Erreur serveur`, + }); + } + } + } + + importResult.value = { added, modified, ignored, errors, details }; + } catch { + error.value = "Erreur lors de l'import."; + } finally { + uploading.value = false; + } + } + + function downloadTemplate() { + globalThis.open("/templates/modele_notes.xlsx", "_blank"); + } + + function _downloadExport() { + // Export notes from the API in the same format + Promise.all([ + fetch("/students/api/students").then((r) => r.json()), + fetch("/notes/api/notes").then((r) => r.json()), + fetch("/notes/api/modules").then((r) => r.json()), + fetch("/notes/api/ue-modules").then((r) => r.json()), + fetch("/notes/api/ues").then((r) => r.json()), + ]).then( + ([ + studentsData, + notesData, + modulesData, + ueModulesData, + uesData, + ]) => { + // Build module map + const modMap = new Map( + modulesData.map((m: { id: string; nom: string }) => [m.id, m.nom]), + ); + + // Get unique module IDs from notes + const moduleIds = [ + ...new Set( + notesData.map((n: { idModule: string }) => n.idModule), + ), + ] as string[]; + + // Group ue-modules by UE + const ueMap = new Map( + uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]), + ); + const umByUE = new Map(); + for (const um of ueModulesData) { + if (!umByUE.has(um.idUE)) umByUE.set(um.idUE, []); + umByUE.get(um.idUE)!.push(um); + } + + // Build column order: group modules by UE, add UE avg columns + const orderedCols: { + id: string; + header: string; + coeff: number | null; + type: "module" | "ue"; + ueId?: number; + }[] = []; + + const usedModules = new Set(); + for (const [ueId, ums] of umByUE) { + for (const um of ums) { + if (!moduleIds.includes(um.idModule)) continue; + orderedCols.push({ + id: um.idModule, + header: `${um.idModule} - ${ + modMap.get(um.idModule) || um.idModule + }`, + coeff: um.coeff, + type: "module", + ueId, + }); + usedModules.add(um.idModule); + } + const ueName = ueMap.get(ueId) || `UE ${ueId}`; + orderedCols.push({ + id: `ue_${ueId}`, + header: ueName, + coeff: ums.reduce( + (s: number, um: { coeff: number }) => s + um.coeff, + 0, + ), + type: "ue", + ueId, + }); + } + // Add modules not linked to any UE + for (const mId of moduleIds) { + if (usedModules.has(mId)) continue; + orderedCols.push({ + id: mId, + header: `${mId} - ${modMap.get(mId) || mId}`, + coeff: null, + type: "module", + }); + } + + // Build note lookup: numEtud -> idModule -> note + const noteLookup = new Map< + number, + Map + >(); + for (const n of notesData) { + if (!noteLookup.has(n.numEtud)) noteLookup.set(n.numEtud, new Map()); + noteLookup.get(n.numEtud)!.set(n.idModule, { + note: n.note, + noteSession2: n.noteSession2, + }); + } + + // Get students who have notes + const studentsWithNotes = studentsData.filter( + (s: Student) => noteLookup.has(s.numEtud), + ); + + // Build header rows + const headerRow: (string | null)[] = [null, null]; + const coeffRow: (number | null)[] = [null, null]; + for (const col of orderedCols) { + headerRow.push(col.header); + coeffRow.push(col.coeff); + } + + // Build session 1 data rows + const s1Rows: (string | number | null)[][] = []; + for (const s of studentsWithNotes) { + const row: (string | number | null)[] = [s.nom, s.prenom]; + const sNotes = noteLookup.get(s.numEtud) || new Map(); + for (const col of orderedCols) { + if (col.type === "module") { + const n = sNotes.get(col.id); + row.push(n ? n.note : null); + } else { + // UE average - calculate + const ueMods = orderedCols.filter( + (c) => c.type === "module" && c.ueId === col.ueId, + ); + let total = 0, coeffSum = 0; + for (const um of ueMods) { + const n = sNotes.get(um.id); + if (n && um.coeff) { + total += n.note * um.coeff; + coeffSum += um.coeff; + } + } + row.push( + coeffSum > 0 + ? Math.round((total / coeffSum) * 100) / 100 + : null, + ); + } + } + s1Rows.push(row); + } + + // Build session 2 data rows + const s2Rows: (string | number | null)[][] = []; + for (const s of studentsWithNotes) { + const row: (string | number | null)[] = [s.nom, s.prenom]; + const sNotes = noteLookup.get(s.numEtud) || new Map(); + for (const col of orderedCols) { + if (col.type === "module") { + const n = sNotes.get(col.id); + // Use session 2 note if available, else session 1 + row.push(n ? (n.noteSession2 ?? n.note) : null); + } else { + const ueMods = orderedCols.filter( + (c) => c.type === "module" && c.ueId === col.ueId, + ); + let total = 0, coeffSum = 0; + for (const um of ueMods) { + const n = sNotes.get(um.id); + if (n && um.coeff) { + const noteVal = n.noteSession2 ?? n.note; + total += noteVal * um.coeff; + coeffSum += um.coeff; + } + } + row.push( + coeffSum > 0 + ? Math.round((total / coeffSum) * 100) / 100 + : null, + ); + } + } + s2Rows.push(row); + } + + const wb = XLSX.utils.book_new(); + const ws1 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s1Rows]); + XLSX.utils.book_append_sheet(wb, ws1, "Session 1"); + const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]); + XLSX.utils.book_append_sheet(wb, ws2, "Session 2"); + const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); + const blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "export_notes.xlsx"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + }, + ); + } + + return ( +
+ { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + }} + /> + +
{ + e.preventDefault(); + dragging.value = true; + }} + onDragLeave={() => (dragging.value = false)} + onDrop={(e) => { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + }} + onClick={() => inputRef.current?.click()} + > + + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + + {importResult.value && ( + (importResult.value = null)} + /> + )} + + {/* Sheet + session selector */} + {sheetNames.value.length > 0 && ( +
+
+ + +
+
+ + +
+
+ )} + + {/* Column preview */} + {columns.value.length > 0 && ( +
+

+ Colonnes detectees : +

+
+ {columns.value.map((col) => ( + + {col.type === "module" + ? "M" + : col.type === "ue" + ? "UE" + : col.type === "malus" + ? "X" + : "?"} {col.code} + + ))} +
+

+ M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus +

+
+ )} + +
+ + + { + /* TODO: fix blob download in Fresh + + */ + } +
+ +

+ Format : Nom | Prenom |{" "} + CODE - ECUE (colonnes notes){" "} + — les colonnes UE et MALUS sont auto-detectees +

+
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx new file mode 100644 index 0000000..5a516f0 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -0,0 +1,587 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Note = { + numEtud: number; + idModule: string; + note: number; + noteSession2: number | null; +}; +type Ajustement = { + numEtud: number; + idUE: number; + valeur: number; + malus: number; +}; + +type Props = { numEtud: number }; + +function fmt(n: number): string { + return `${Math.round(n * 10) / 10}/20`; +} + +function noteClass(n: number): string { + return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail"; +} + +/** Returns the effective note (session 2 if exists, otherwise session 1). */ +function effectiveNote(n: Note): number { + return n.noteSession2 ?? n.note; +} + +export default function NoteRecap({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [ueList, setUeList] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [moduleMap, setModuleMap] = useState>(new Map()); + const [noteMap, setNoteMap] = useState>(new Map()); + const [ajustements, setAjustements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingNote, setEditingNote] = useState< + { idModule: string; field: "note" | "noteSession2"; value: string } | null + >(null); + const [ajustInputs, setAjustInputs] = useState< + Record + >({}); + + async function load() { + try { + const sRes = await fetch(`/students/api/students/${numEtud}`); + if (!sRes.ok) throw new Error("Eleve introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + + const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch( + `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + ), + fetch("/notes/api/modules"), + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (uesRes.ok) setUeList(await uesRes.json()); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) { + const mods: Module[] = await mRes.json(); + setModuleMap(new Map(mods.map((m) => [m.id, m.nom]))); + } + if (notesRes.ok) { + const ns: Note[] = await notesRes.json(); + setNoteMap(new Map(ns.map((n) => [n.idModule, n]))); + } + if (ajustRes.ok) { + const aj: Ajustement[] = await ajustRes.json(); + setAjustements(aj); + const inputs: Record = {}; + for (const a of aj) { + inputs[a.idUE] = { + valeur: String(a.valeur), + malus: String(a.malus), + }; + } + setAjustInputs(inputs); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [numEtud]); + + function calcAvg(ueMods: UEModule[]): number | null { + let total = 0, + coeff = 0; + for (const um of ueMods) { + const n = noteMap.get(um.idModule); + if (n === undefined) return null; + const val = effectiveNote(n); + total += val * um.coeff; + coeff += um.coeff; + } + return coeff > 0 ? total / coeff : null; + } + + async function saveNote( + idModule: string, + field: "note" | "noteSession2", + value: string, + ) { + if (value.trim() === "" && field === "noteSession2") { + // Clear session 2 note + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noteSession2: null }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated)); + } + setEditingNote(null); + return; + } + + const note = parseFloat(value.replace(",", ".")); + if (isNaN(note) || note < 0 || note > 20) { + setEditingNote(null); + return; + } + + const existing = noteMap.get(idModule); + + if (existing) { + // Update + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ [field]: note }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated)); + } + } else { + // Create + const body: Record = { + numEtud, + idModule, + note: field === "note" ? note : 0, + }; + if (field === "noteSession2") body.noteSession2 = note; + const res = await fetch("/notes/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) { + const created: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, created)); + } + } + setEditingNote(null); + } + + async function applyAjust(idUE: number) { + const inputs = ajustInputs[idUE]; + const val = parseFloat((inputs?.valeur ?? "").replace(",", ".")); + const malus = parseInt(inputs?.malus ?? "0"); + if (isNaN(val) || val < 0 || val > 20) return; + if (isNaN(malus) || malus < 0) return; + + const existing = ajustements.find((a) => a.idUE === idUE); + const res = existing + ? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ valeur: val, malus }), + }) + : await fetch("/notes/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud, idUE, valeur: val, malus }), + }); + if (res.ok) { + const updated: Ajustement = await res.json(); + setAjustements((prev) => + existing + ? prev.map((a) => (a.idUE === idUE ? updated : a)) + : [...prev, updated] + ); + } + } + + async function resetAjust(idUE: number) { + const res = await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "DELETE", + }); + if (res.ok) { + setAjustements((prev) => prev.filter((a) => a.idUE !== idUE)); + setAjustInputs((prev) => { + const c = { ...prev }; + delete c[idUE]; + return c; + }); + } + } + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + if (error && !student) { + return ( +
+

{error}

+
+ ); + } + if (!student) return null; + + return ( +
+ + ← Retour a la liste + + +

+ Recap notes – {student.prenom} {student.nom} +

+ +
+ {student.numEtud} + + {student.prenom} {student.nom} + + {student.idPromo} +
+ + {error &&

{error}

} + + {ueList.length === 0 + ? ( +

+ Aucune UE configuree pour cette promotion. +

+ ) + : ueList.map((ue) => { + const ueMods = ueModules.filter((um) => um.idUE === ue.id); + const avg = calcAvg(ueMods); + const ajust = ajustements.find((a) => a.idUE === ue.id); + + // Final displayed average: if ajust.valeur exists it replaces avg, then subtract malus + let finalAvg = avg; + if (ajust) { + finalAvg = ajust.valeur; + if (ajust.malus > 0) { + finalAvg = (finalAvg ?? 0) - ajust.malus; + } + } + + return ( +
+ {/* UE header */} +
+

{ue.nom}

+ {avg !== null && ( + + Moy. calculee : {fmt(avg)} + + )} + {ajust && ( + + Ajust. actif : {fmt(ajust.valeur)} + + )} + {ajust && ajust.malus > 0 && ( + + Malus : -{ajust.malus} + + )} +
+ + {/* ECUE rows */} + {ueMods.length === 0 + ? ( +

+ Aucun ECUE associe a cette UE pour cette promotion. +

+ ) + : ( +
+ {ueMods.map((um) => { + const noteObj = noteMap.get(um.idModule); + const noteVal = noteObj?.note; + const noteS2 = noteObj?.noteSession2; + const effective = noteObj + ? effectiveNote(noteObj) + : undefined; + const nomMod = moduleMap.get(um.idModule) ?? um.idModule; + + return ( +
+ + + {um.idModule} + + {nomMod} + + + coef {um.coeff} + + + {/* Session 1 note */} + {editingNote?.idModule === um.idModule && + editingNote.field === "note" + ? ( +
+ + setEditingNote({ + ...editingNote, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + "note", + editingNote.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote( + um.idModule, + "note", + editingNote.value, + )} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + field: "note", + value: noteVal !== undefined + ? String(noteVal) + : "", + })} + > + S1:{" "} + {noteVal !== undefined ? fmt(noteVal) : "—/20"} + + )} + + {/* Session 2 note */} + {editingNote?.idModule === um.idModule && + editingNote.field === "noteSession2" + ? ( +
+ + setEditingNote({ + ...editingNote, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + "noteSession2", + editingNote.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote( + um.idModule, + "noteSession2", + editingNote.value, + )} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + field: "noteSession2", + value: noteS2 != null ? String(noteS2) : "", + })} + > + S2: {noteS2 != null ? fmt(noteS2) : "—"} + + )} + + {/* Effective note indicator */} + {noteS2 != null && ( + + → {fmt(effective!)} + + )} +
+ ); + })} +
+ )} + + {/* Ajustement + Malus */} +
+

Ajustement de la moyenne UE

+

+ La valeur remplace la moyenne calculee. Le malus est + soustrait. +

+
+
+ + Val: + + + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: { + valeur: (e.target as HTMLInputElement).value, + malus: prev[ue.id]?.malus ?? "0", + }, + }))} + /> + /20 +
+
+ + Malus: + + + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: { + valeur: prev[ue.id]?.valeur ?? "", + malus: (e.target as HTMLInputElement).value, + }, + }))} + /> +
+ + {ajust && ( + <> + + + Affiche : {fmt(ajust.valeur)} + {ajust.malus > 0 + ? ` - ${ajust.malus} = ${ + fmt(ajust.valeur - ajust.malus) + }` + : ""} + {avg !== null ? ` (calculee : ${fmt(avg)})` : ""} + + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx new file mode 100644 index 0000000..35cc897 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from "preact/hooks"; + +type Note = { + numEtud: number; + idModule: string; + note: number; + noteSession2: number | null; +}; +type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Ajustement = { + numEtud: number; + idUE: number; + valeur: number; + malus: number; +}; + +type Props = { + numEtud: number | null; + prenom: string; +}; + +function scoreClass(score: number | null): string { + if (score === null) return "score-none"; + return score >= 10 ? "score-good" : "score-warn"; +} + +function avgClass(avg: number | null): string { + if (avg === null) return ""; + return avg >= 10 ? "avg-good" : "avg-warn"; +} + +/** Returns the effective note (session 2 if exists, otherwise session 1). */ +function effectiveNote(n: Note): number { + return n.noteSession2 ?? n.note; +} + +export default function NotesView({ numEtud, prenom }: Props) { + const [notes, setNotes] = useState([]); + const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [ajustements, setAjustements] = useState([]); + const [promos, setPromos] = useState([]); + const [activePromo, setActivePromo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (numEtud === null) { + setLoading(false); + return; + } + + async function load() { + try { + const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/notes/api/modules"), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (!notesRes.ok || !uesRes.ok || !ueModRes.ok) { + throw new Error("Erreur lors du chargement"); + } + + const [notesData, uesData, ueModData, modData, ajData] = await Promise + .all([ + notesRes.json(), + uesRes.json(), + ueModRes.json(), + modRes.ok ? modRes.json() : [], + ajRes.ok ? ajRes.json() : [], + ]); + + setNotes(notesData); + setUes(uesData); + setUeModules(ueModData); + setModules(modData); + setAjustements(ajData); + + const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule)); + const relevantPromos = [ + ...new Set( + ueModData + .filter((um: UEModule) => noteModuleIds.has(um.idModule)) + .map((um: UEModule) => um.idPromo), + ), + ] as string[]; + + setPromos(relevantPromos); + if (relevantPromos.length > 0) setActivePromo(relevantPromos[0]); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur inconnue"); + } finally { + setLoading(false); + } + } + + load(); + }, [numEtud]); + + if (numEtud === null) { + return ( +
+

+ Bonjour {prenom}{" "} + — aucun dossier etudiant n'est associe a votre compte. +

+
+ ); + } + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + const filteredUeModules = activePromo + ? ueModules.filter((um) => um.idPromo === activePromo) + : ueModules; + + const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))]; + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + const noteMap = Object.fromEntries( + notes.map((n) => [n.idModule, n]), + ); + const ajMap = Object.fromEntries( + ajustements.map((a) => [a.idUE, a]), + ); + + return ( +
+ {promos.length > 1 && ( +
+ {promos.map((p) => ( + + ))} +
+ )} + + {ueIds.length === 0 && ( +

Aucune note disponible pour cette periode.

+ )} + + {ueIds.map((ueId) => { + const ue = ues.find((u) => u.id === ueId); + if (!ue) return null; + + const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId); + let weightedSum = 0; + let coveredCoeff = 0; + ueModsForUE.forEach((um) => { + const noteObj = noteMap[um.idModule]; + if (noteObj) { + const val = effectiveNote(noteObj); + weightedSum += val * um.coeff; + coveredCoeff += um.coeff; + } + }); + + const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null; + const ajust = ajMap[ueId] ?? null; + + // If ajust.valeur exists, it replaces the calculated average + // Then malus is subtracted + let finalAvg: number | null = avg; + if (ajust) { + finalAvg = ajust.valeur; + if (ajust.malus > 0) { + finalAvg = (finalAvg ?? 0) - ajust.malus; + } + } + + return ( +
+
+

UE : {ue.nom}

+ {finalAvg !== null + ? ( +

+ Moyenne : {finalAvg.toFixed(2)}/20 + {ajust && ajust.malus > 0 && ( + (malus : -{ajust.malus}) + )} +

+ ) + :

Notes non disponibles

} +
+ + {ueModsForUE.map((um) => { + const mod = moduleMap[um.idModule]; + const noteObj = noteMap[um.idModule] ?? null; + const effective = noteObj ? effectiveNote(noteObj) : null; + const hasS2 = noteObj?.noteSession2 != null; + + return ( +
+ + {mod ? mod.id : um.idModule} —{" "} + {mod ? mod.nom : "ECUE inconnu"} (coef {um.coeff}) + + + {effective !== null ? `${effective}/20` : "—"} + {hasS2 && ( + + (S1: {noteObj!.note}) + + )} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 36b0f28..38f1625 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -4,11 +4,13 @@ const properties: AppProperties = { name: "PolyNotes", icon: "school", pages: { - index: "Homepage", - notes: "Notes", - courses: "Courses management", + index: "Accueil", + notes: "Mes notes", + courses: "Consulter", + import: "Import Notes", }, - adminOnly: ["courses", "students"], + adminOnly: ["courses", "import"], + studentOnly: ["notes"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/[slug].tsx b/routes/(apps)/notes/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/notes/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/notes/api/ajustements.ts b/routes/(apps)/notes/api/ajustements.ts new file mode 100644 index 0000000..b40e61e --- /dev/null +++ b/routes/(apps)/notes/api/ajustements.ts @@ -0,0 +1,98 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ajustements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #48 GET /ajustements + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + const idUEParam = url.searchParams.get("idUE"); + + let query = db.select().from(ajustements).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(ajustements.numEtud, numEtud)); + } + + if (idUEParam) { + const idUE = parseInt(idUEParam); + if (isNaN(idUE)) { + return new Response("Paramètre idUE invalide", { status: 400 }); + } + query = query.where(eq(ajustements.idUE, idUE)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching ajustements:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #49 POST /ajustements + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + try { + const body: { + numEtud: number; + idUE: number; + valeur: number; + malus?: number; + } = await request.json(); + + if (!body.numEtud || !body.idUE || body.valeur === undefined) { + return new Response( + JSON.stringify({ error: "Champs requis: numEtud, idUE, valeur" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if ( + body.malus !== undefined && + (!Number.isInteger(body.malus) || body.malus < 0) + ) { + return new Response( + JSON.stringify({ error: "malus doit être un entier >= 0" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [created] = await db + .insert(ajustements) + .values({ + numEtud: body.numEtud, + idUE: body.idUE, + valeur: body.valeur, + malus: body.malus ?? 0, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + console.error("Error creating ajustement:", error); + return new Response("Failed to create ajustement", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts new file mode 100644 index 0000000..b527cdc --- /dev/null +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -0,0 +1,123 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ajustements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ajustement introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // #50 GET /ajustements/{numEtud}/{idUE} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + const idUE = Number(context.params.idUE); + + if (isNaN(numEtud) || isNaN(idUE)) { + return new Response("Paramètres invalides", { status: 400 }); + } + + const ajustement = await db + .select() + .from(ajustements) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) + .then((rows) => rows[0] ?? null); + + if (!ajustement) return NOT_FOUND(); + + return new Response(JSON.stringify(ajustement), { + headers: { "content-type": "application/json" }, + }); + }, + + // #51 PUT /ajustements/{numEtud}/{idUE} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + const idUE = Number(context.params.idUE); + + if (isNaN(numEtud) || isNaN(idUE)) { + return new Response("Paramètres invalides", { status: 400 }); + } + + const body: { valeur: number; malus?: number } = await request.json(); + + if (body.valeur === undefined) { + return new Response(JSON.stringify({ error: "Champ requis: valeur" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + if ( + body.malus !== undefined && + (!Number.isInteger(body.malus) || body.malus < 0) + ) { + return new Response( + JSON.stringify({ error: "malus doit être un entier >= 0" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: { valeur: number; malus?: number } = { valeur: body.valeur }; + if (body.malus !== undefined) { + set.malus = body.malus; + } + + const [updated] = await db + .update(ajustements) + .set(set) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // #52 DELETE /ajustements/{numEtud}/{idUE} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + const idUE = Number(context.params.idUE); + + if (isNaN(numEtud) || isNaN(idUE)) { + return new Response("Paramètres invalides", { status: 400 }); + } + + const [deleted] = await db + .delete(ajustements) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/notes/api/modules.ts b/routes/(apps)/notes/api/modules.ts new file mode 100644 index 0000000..3333369 --- /dev/null +++ b/routes/(apps)/notes/api/modules.ts @@ -0,0 +1,12 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { modules } from "$root/databases/schema.ts"; + +export const handler: Handlers = { + async GET() { + const rows = await db.select().from(modules); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts new file mode 100644 index 0000000..498d007 --- /dev/null +++ b/routes/(apps)/notes/api/notes.ts @@ -0,0 +1,95 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { notes } from "../../../../databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #42 GET /notes + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + const idModule = url.searchParams.get("idModule"); + + let query = db.select().from(notes).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(notes.numEtud, numEtud)); + } + + if (idModule) { + query = query.where(eq(notes.idModule, idModule)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching notes:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #43 POST /notes + async POST(request) { + try { + const body = await request.json(); + const { note, numEtud, idModule, noteSession2 } = body; + + if (note === undefined || !numEtud || !idModule) { + return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { + status: 400, + }); + } + + if (typeof note !== "number" || note < 0 || note > 20) { + return new Response("Champ 'note' doit être un nombre entre 0 et 20", { + status: 400, + }); + } + + if ( + noteSession2 !== undefined && noteSession2 !== null && + (typeof noteSession2 !== "number" || noteSession2 < 0 || + noteSession2 > 20) + ) { + return new Response( + "Champ 'noteSession2' doit être un nombre entre 0 et 20", + { status: 400 }, + ); + } + + const values: { + note: number; + numEtud: number; + idModule: string; + noteSession2?: number | null; + } = { + note, + numEtud, + idModule, + }; + if (noteSession2 !== undefined) { + values.noteSession2 = noteSession2; + } + + const result = await db.insert(notes).values(values) + .returning(); + + return new Response(JSON.stringify(result[0]), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error creating note:", error); + return new Response("Failed to create note", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts new file mode 100644 index 0000000..544e56a --- /dev/null +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -0,0 +1,165 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../../../databases/db.ts"; +import { notes } from "../../../../../../databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #45 GET /notes/:numEtud/:idModule + async GET(_request, context) { + try { + const numEtud = parseInt(context.params.numEtud); + const { idModule } = context.params; + + if (isNaN(numEtud)) { + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await db.select().from(notes).where( + and( + eq(notes.numEtud, numEtud), + eq(notes.idModule, idModule), + ), + ); + + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify(result[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching note:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #46 PUT /notes/:numEtud/:idModule + async PUT(request, context) { + try { + const numEtud = parseInt(context.params.numEtud); + const { idModule } = context.params; + + if (isNaN(numEtud)) { + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const body = await request.json(); + const { note, noteSession2 } = body; + + if (note === undefined && noteSession2 === undefined) { + return new Response("Au moins 'note' ou 'noteSession2' requis", { + status: 400, + }); + } + + if ( + note !== undefined && + (typeof note !== "number" || note < 0 || note > 20) + ) { + return new Response("Champ 'note' doit être un nombre entre 0 et 20", { + status: 400, + }); + } + + if ( + noteSession2 !== undefined && noteSession2 !== null && + (typeof noteSession2 !== "number" || noteSession2 < 0 || + noteSession2 > 20) + ) { + return new Response( + "Champ 'noteSession2' doit être un nombre entre 0 et 20", + { status: 400 }, + ); + } + + const set: { note?: number; noteSession2?: number | null } = {}; + if (note !== undefined) set.note = note; + if (noteSession2 !== undefined) set.noteSession2 = noteSession2; + + const result = await db.update(notes).set(set).where( + and( + eq(notes.numEtud, numEtud), + eq(notes.idModule, idModule), + ), + ).returning(); + + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify(result[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error updating note:", error); + return new Response("Failed to update note", { status: 500 }); + } + }, + + // #47 DELETE /notes/:numEtud/:idModule + async DELETE(_request, context) { + try { + const numEtud = parseInt(context.params.numEtud); + const { idModule } = context.params; + + if (isNaN(numEtud)) { + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await db.delete(notes).where( + and( + eq(notes.numEtud, numEtud), + eq(notes.idModule, idModule), + ), + ).returning(); + + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(null, { status: 204 }); + } catch (error) { + console.error("Error deleting note:", error); + return new Response("Failed to delete note", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/notes/api/notes/import-xlsx.ts b/routes/(apps)/notes/api/notes/import-xlsx.ts new file mode 100644 index 0000000..7b01333 --- /dev/null +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -0,0 +1,70 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../../databases/db.ts"; +import { notes } from "../../../../../databases/schema.ts"; + +export const handler: Handlers = { + //# 44 POST /notes/import-xlsx + async POST(request) { + try { + const formData = await request.formData(); + const file = formData.get("file"); + const idModule = formData.get("idModule"); + + if (!file || !(file instanceof File)) { + return new Response("Champ 'file' manquant", { status: 400 }); + } + + if (!idModule || typeof idModule !== "string") { + return new Response("Champ 'idModule' manquant", { status: 400 }); + } + + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet) as { + numEtud: number; + note: number; + noteSession2?: number; + }[]; + + for (const row of rows) { + const { numEtud, note, noteSession2 } = row; + + if (!numEtud || note === undefined) { + continue; + } + + const values: { + numEtud: number; + idModule: string; + note: number; + noteSession2?: number | null; + } = { + numEtud, + idModule, + note, + }; + const set: { note: number; noteSession2?: number | null } = { note }; + + if (noteSession2 !== undefined) { + values.noteSession2 = noteSession2; + set.noteSession2 = noteSession2; + } + + await db.insert(notes) + .values(values) + .onConflictDoUpdate({ + target: [notes.numEtud, notes.idModule], + set, + }); + } + + return new Response(null, { status: 204 }); + } catch (error) { + console.error("Error importing notes:", error); + return new Response("Failed to import notes", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts new file mode 100644 index 0000000..08e3a11 --- /dev/null +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -0,0 +1,28 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ueModules } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + async GET(request) { + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + const idUEParam = url.searchParams.get("idUE"); + const idUE = idUEParam ? parseInt(idUEParam) : null; + + if (idUEParam && isNaN(idUE!)) { + return new Response("Paramètre idUE invalide", { status: 400 }); + } + + const rows = await db.select().from(ueModules).where( + and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, + ), + ); + + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts new file mode 100644 index 0000000..09230a9 --- /dev/null +++ b/routes/(apps)/notes/api/ues.ts @@ -0,0 +1,12 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ues } from "$root/databases/schema.ts"; + +export const handler: Handlers = { + async GET() { + const rows = await db.select().from(ues); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/edition/[numEtud].tsx b/routes/(apps)/notes/edition/[numEtud].tsx new file mode 100644 index 0000000..437d4c4 --- /dev/null +++ b/routes/(apps)/notes/edition/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function EditionPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 3ac215d..6f9f8ba 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -3,12 +3,14 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminConsultNotes from "../../(_islands)/AdminConsultNotes.tsx"; // deno-lint-ignore require-await -async function Courses(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Courses(_request: Request, _context: FreshContext) { + return ; } +export { Courses as Page }; export const config = getPartialsConfig(); export default makePartials(Courses); diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx new file mode 100644 index 0000000..3f56e2d --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -0,0 +1,24 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import ImportNotes from "../../(_islands)/ImportNotes.tsx"; + +// deno-lint-ignore require-await +async function ImportNotesPage( + _request: Request, + _context: FreshContext, +) { + return ( +
+

Importer des Notes

+ +
+ ); +} + +export { ImportNotesPage as Page }; +export const config = getPartialsConfig(); +export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/notes/partials/index.tsx b/routes/(apps)/notes/partials/index.tsx index 2971e0e..e5d80ce 100644 --- a/routes/(apps)/notes/partials/index.tsx +++ b/routes/(apps)/notes/partials/index.tsx @@ -3,11 +3,53 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + + return ( +
+

PolyNotes

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee + ? ( +

+ Consultez les{" "} + + notes des élèves + {" "} + ou gérez les{" "} + + UEs + + . +

+ ) + : ( +

+ Consultez vos{" "} + + notes + + . +

+ )} +
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 1e3bbc1..de9e686 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -1,14 +1,59 @@ +import { FreshContext } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { students } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; import { getPartialsConfig, makePartials, } from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { CasContent, State } from "$root/defaults/interfaces.ts"; +import NotesView from "../(_islands)/NotesView.tsx"; -// deno-lint-ignore require-await -async function Notes(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Notes( + _request: Request, + context: FreshContext, +) { + const session = (context.state as unknown as { session: CasContent }).session; + + let numEtud: number | null = null; + try { + if (session.eduPersonPrimaryAffiliation === "student") { + // Students: uid is "21212006" in AMU CAS — strip non-digit prefix + const etudId = parseInt(session.uid.replace(/^\D+/, ""), 10); + if (!isNaN(etudId)) { + const student = await db + .select() + .from(students) + .where(eq(students.numEtud, etudId)) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } + } else { + // Employees: look up by nom/prenom + const student = await db + .select() + .from(students) + .where( + and( + eq(students.nom, session.sn), + eq(students.prenom, session.givenName), + ), + ) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } + } catch { + // DB lookup failed — island will show fallback message + } + + return ( + + ); } +export { Notes as Page }; export const config = getPartialsConfig(); export default makePartials(Notes); diff --git a/routes/(apps)/notes/recap/[numEtud].tsx b/routes/(apps)/notes/recap/[numEtud].tsx new file mode 100644 index 0000000..208da0f --- /dev/null +++ b/routes/(apps)/notes/recap/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function RecapPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/(apps)/stages/(_islands)/StagesOverview.tsx b/routes/(apps)/stages/(_islands)/StagesOverview.tsx new file mode 100644 index 0000000..de0af6f --- /dev/null +++ b/routes/(apps)/stages/(_islands)/StagesOverview.tsx @@ -0,0 +1,558 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 40; + +export default function StagesOverview( + { initialNumEtud }: { initialNumEtud?: number } = {}, +) { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [stagesList, setStagesList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingStage, setEditingStage] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setStagesList(stData); + if (initialNumEtud) { + const found = (sData as Student[]).find((s) => + s.numEtud === initialNumEtud + ); + if (found) setDetailStudent(found); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + function openStudent(s: Student) { + setDetailStudent(s); + history.pushState(null, "", `/stages/overview/${s.numEtud}`); + } + + function closeStudent() { + setDetailStudent(null); + setEditingStage(null); + setShowAddForm(false); + history.pushState(null, "", "/stages/overview"); + } + + if (detailStudent) { + return ( + s.numEtud === detailStudent.numEtud)} + allStages={stagesList} + editingStage={editingStage} + setEditingStage={setEditingStage} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={closeStudent} + onReload={load} + /> + ); + } + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + if (error) { + return ( +
+

{error}

+
+ ); + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + + const stagesByStudent = (numEtud: number) => + stagesList.filter((s) => s.numEtud === numEtud); + + return ( +
+

Suivi des stages

+ +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + openStudent(s)} + /> +
+ ); +} + +// ─── Liste View ───────────────────────────────────────────── + +function ListView( + { students, stagesByStudent, onConsult }: { + students: Student[]; + stagesByStudent: (n: number) => Stage[]; + onConsult: (s: Student) => void; + }, +) { + return ( +
+ + + + + + + + + + + + {students.length === 0 + ? ( + + + + ) + : students.map((s) => { + const stages = stagesByStudent(s.numEtud); + const weeks = stages.reduce((sum, st) => sum + st.duree, 0); + const ok = weeks >= REQUIRED_WEEKS; + return ( + + + + + + + + ); + })} + +
N° étud.NomPrénomSemainesActions
Aucun élève trouvé
{s.numEtud}{s.nom}{s.prenom} + + {weeks}/{REQUIRED_WEEKS} + + + +
+
+ ); +} + +// ─── Detail View ──────────────────────────────────────────── + +function DetailView( + { + student, + stages, + allStages, + editingStage, + setEditingStage, + showAddForm, + setShowAddForm, + onBack, + onReload, + }: { + student: Student; + stages: Stage[]; + allStages: Stage[]; + editingStage: Stage | null; + setEditingStage: (s: Stage | null) => void; + showAddForm: boolean; + setShowAddForm: (v: boolean) => void; + onBack: () => void; + onReload: () => Promise; + }, +) { + const weeks = stages.reduce((sum, s) => sum + s.duree, 0); + const entreprises = [ + ...new Set(allStages.map((s) => s.nomEntreprise).filter(Boolean)), + ]; + + async function deleteStage(id: number) { + if (!confirm("Supprimer ce stage ?")) return; + await fetch(`/stages/api/stages/${id}`, { method: "DELETE" }); + await onReload(); + } + + return ( +
+ +

+ Consulter : {student.prenom} {student.nom} + = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} semaines + +

+ + {stages.length === 0 && ( +

+ Aucun stage enregistré. +

+ )} + + {stages.map((stage, i) => { + const isEditing = editingStage?.id === stage.id; + + if (isEditing) { + return ( + setEditingStage(null)} + onSave={async () => { + setEditingStage(null); + await onReload(); + }} + /> + ); + } + + return ( +
+
+

+ Stage {i + 1} +

+

+ Durée : {stage.duree} semaine(s) +

+
+
+

+ Entreprise : {stage.nomEntreprise} + {stage.mission && — {stage.mission}} +

+
+ + +
+
+
+ ); + })} + + {showAddForm + ? ( + setShowAddForm(false)} + onSave={async () => { + setShowAddForm(false); + await onReload(); + }} + /> + ) + : ( + + )} +
+ ); +} + +// ─── Inline forms ─────────────────────────────────────────── + +function StageEditForm( + { stage, entreprises, onCancel, onSave }: { + stage: Stage; + entreprises: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState(String(stage.duree)); + const [nomEntreprise, setNomEntreprise] = useState(stage.nomEntreprise); + const [mission, setMission] = useState(stage.mission ?? ""); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch(`/stages/api/stages/${stage.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + duree: parseInt(duree), + nomEntreprise, + mission: mission || null, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la modification"); + } finally { + setBusy(false); + } + } + + return ( +
+

Modifier le stage #{stage.id}

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + + setNomEntreprise((e.target as HTMLInputElement).value)} + /> + + {entreprises.map((e) => +
+
+ + setMission((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ ); +} + +function StageAddForm( + { numEtud, entreprises, onCancel, onSave }: { + numEtud: number; + entreprises: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState("4"); + const [nomEntreprise, setNomEntreprise] = useState(""); + const [mission, setMission] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch("/stages/api/stages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + numEtud, + duree: parseInt(duree), + nomEntreprise, + mission: mission || null, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la création"); + } finally { + setBusy(false); + } + } + + return ( +
+

Nouveau stage

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + + setNomEntreprise((e.target as HTMLInputElement).value)} + /> + + {entreprises.map((e) => +
+
+ + setMission((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ ); +} diff --git a/routes/(apps)/stages/(_props)/props.ts b/routes/(apps)/stages/(_props)/props.ts new file mode 100644 index 0000000..feffd2b --- /dev/null +++ b/routes/(apps)/stages/(_props)/props.ts @@ -0,0 +1,15 @@ +import { AppProperties } from "$root/defaults/interfaces.ts"; + +const properties: AppProperties = { + name: "Stages", + icon: "work", + pages: { + index: "Accueil", + overview: "Suivi des stages", + }, + adminOnly: ["overview"], + employeeOnly: true, + hint: "Suivi des stages et semaines", +}; + +export default properties; diff --git a/routes/(apps)/stages/[...slug].tsx b/routes/(apps)/stages/[...slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/stages/[...slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/stages/api/stages.ts b/routes/(apps)/stages/api/stages.ts new file mode 100644 index 0000000..602381c --- /dev/null +++ b/routes/(apps)/stages/api/stages.ts @@ -0,0 +1,84 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { stages } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // GET /stages — list all, optional ?numEtud filter + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + + let query = db.select().from(stages).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(stages.numEtud, numEtud)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching stages:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // POST /stages — create stage (employee only) + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + try { + const body = await request.json(); + const { numEtud, duree, nomEntreprise, mission } = body; + + if (!numEtud || duree === undefined || !nomEntreprise) { + return new Response( + JSON.stringify({ + error: "Champs requis: numEtud, duree, nomEntreprise", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (!Number.isInteger(duree) || duree < 1) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [created] = await db + .insert(stages) + .values({ + numEtud, + duree, + nomEntreprise, + mission: mission ?? null, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + console.error("Error creating stage:", error); + return new Response("Failed to create stage", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/stages/api/stages/[idStage].ts b/routes/(apps)/stages/api/stages/[idStage].ts new file mode 100644 index 0000000..2fea148 --- /dev/null +++ b/routes/(apps)/stages/api/stages/[idStage].ts @@ -0,0 +1,122 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites, stages } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Stage introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // GET /stages/:idStage + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + const row = await db + .select() + .from(stages) + .where(eq(stages.id, idStage)) + .then((rows) => rows[0] ?? null); + + if (!row) return NOT_FOUND(); + + return new Response(JSON.stringify(row), { + headers: { "content-type": "application/json" }, + }); + }, + + // PUT /stages/:idStage (employee only) + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + const body = await request.json(); + const { duree, nomEntreprise, mission } = body; + + if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: Record = {}; + if (duree !== undefined) set.duree = duree; + if (nomEntreprise !== undefined) set.nomEntreprise = nomEntreprise; + if (mission !== undefined) set.mission = mission; + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ à modifier requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(stages) + .set(set) + .where(eq(stages.id, idStage)) + .returning(); + + if (!updated) return NOT_FOUND(); + + // If duration changed and this stage is linked as a mobility, update the mobility too + if (duree !== undefined) { + await db + .update(mobilites) + .set({ duree }) + .where(eq(mobilites.idStage, idStage)); + } + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /stages/:idStage (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + // Remove linked mobilites first (FK constraint) + await db.delete(mobilites).where(eq(mobilites.idStage, idStage)); + + const [deleted] = await db + .delete(stages) + .where(eq(stages.id, idStage)) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/stages/index.tsx b/routes/(apps)/stages/index.tsx new file mode 100644 index 0000000..1d82f7f --- /dev/null +++ b/routes/(apps)/stages/index.tsx @@ -0,0 +1,2 @@ +import makeIndex from "$root/defaults/makeIndex.ts"; +export default makeIndex(import.meta.dirname!); diff --git a/routes/(apps)/stages/partials/index.tsx b/routes/(apps)/stages/partials/index.tsx new file mode 100644 index 0000000..cfbf369 --- /dev/null +++ b/routes/(apps)/stages/partials/index.tsx @@ -0,0 +1,30 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; + +// deno-lint-ignore require-await +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Stages

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+

Suivi des stages : 40 semaines requises par élève.

+
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(Index); diff --git a/routes/(apps)/stages/partials/overview.tsx b/routes/(apps)/stages/partials/overview.tsx new file mode 100644 index 0000000..d0d496c --- /dev/null +++ b/routes/(apps)/stages/partials/overview.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/stages/partials/overview/[numEtud].tsx b/routes/(apps)/stages/partials/overview/[numEtud].tsx new file mode 100644 index 0000000..3a06562 --- /dev/null +++ b/routes/(apps)/stages/partials/overview/[numEtud].tsx @@ -0,0 +1,20 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index f67036b..86132e9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,45 +1,313 @@ import { useEffect, useState } from "preact/hooks"; -import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx"; -type SingleUserResponse = { promo: Promotion; student: Student }; -type ManyUsersResponse = { promos: Promotion[]; students: Student[] }; - -type APIResponse = SingleUserResponse | ManyUsersResponse; +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; export default function ConsultStudents() { - const [data, setData] = useState(null); + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [bulkPromo, setBulkPromo] = useState(""); + const [bulkBusy, setBulkBusy] = useState(false); + + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les élèves"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } useEffect(() => { - const fetchData = async () => { - const response = await fetch("/students/api/students"); - if (!response.ok) { - setError("Failed to load data. Please try again later."); - } - - const result: APIResponse = await response.json(); - setData(result); - }; - - fetchData(); + load(); }, []); + async function deleteStudent(numEtud: number) { + if (!confirm(`Supprimer l'élève #${numEtud} ?`)) return; + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + setSelected((prev) => { + const next = new Set(prev); + next.delete(numEtud); + return next; + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + + const filteredIds = new Set(filtered.map((s) => s.numEtud)); + const selectedInView = [...selected].filter((id) => filteredIds.has(id)); + const allFilteredSelected = filtered.length > 0 && + filtered.every((s) => selected.has(s.numEtud)); + + function toggleOne(numEtud: number) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(numEtud)) next.delete(numEtud); + else next.add(numEtud); + return next; + }); + } + + function toggleAll() { + if (allFilteredSelected) { + setSelected((prev) => { + const next = new Set(prev); + for (const s of filtered) next.delete(s.numEtud); + return next; + }); + } else { + setSelected((prev) => { + const next = new Set(prev); + for (const s of filtered) next.add(s.numEtud); + return next; + }); + } + } + + async function bulkDelete() { + const count = selectedInView.length; + if (count === 0) return; + if ( + !confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`) + ) return; + setBulkBusy(true); + try { + const results = await Promise.all( + selectedInView.map((id) => + fetch(`/students/api/students/${id}`, { method: "DELETE" }) + ), + ); + const failed = results.filter((r) => !r.ok).length; + if (failed > 0) setError(`${failed} suppression(s) échouée(s)`); + setSelected(new Set()); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setBulkBusy(false); + } + } + + async function bulkChangePromo() { + if (!bulkPromo || selectedInView.length === 0) return; + setBulkBusy(true); + try { + const results = await Promise.all( + selectedInView.map((id) => + fetch(`/students/api/students/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: bulkPromo }), + }) + ), + ); + const failed = results.filter((r) => !r.ok).length; + if (failed > 0) setError(`${failed} modification(s) échouée(s)`); + setSelected(new Set()); + setBulkPromo(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setBulkBusy(false); + } + } + return ( - <> - {error &&

{error}

} - {data && ((Object.hasOwn(data, "student")) - ? ( - - ) - : (data as ManyUsersResponse).promos.map((promo) => ( - - )))} - +
+

Gestion des Élèves

+ + {error &&

{error}

} + + + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {/* Bulk actions bar */} + {selectedInView.length > 0 && ( +
+ + {selectedInView.length} sélectionné(s) + +
+ + + +
+
+ )} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + + ))} + +
+ 0} + onChange={toggleAll} + /> + N° étud.NomPrénomPromoActions
+ Aucun élève trouvé +
+ toggleOne(s.numEtud)} + /> + {s.numEtud}{s.nom}{s.prenom}{s.idPromo} +
+ + + + + + +
+
+
+ )} +
); } diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index e69de29..b72abf4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -0,0 +1,297 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promo = { id: string; annee: string }; +type Module = { id: string; nom: string }; +type Mobilite = { id: number; duree: number; status: string }; +type Stage = { id: number; duree: number }; + +type Props = { numEtud: number }; + +function anneeLabel(idPromo: string): string { + const m = idPromo.match(/^(\d+)A/); + if (!m) return ""; + const n = m[1]; + if (n === "3") return "3ème année"; + if (n === "4") return "4ème année"; + if (n === "5") return "5ème année"; + return `${n}ème année`; +} + +export default function EditStudents({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [promos, setPromos] = useState([]); + const [_modules, setModules] = useState([]); + const [mobWeeks, setMobWeeks] = useState(0); + const [stageWeeks, setStageWeeks] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + // Edit form state + const [nom, setNom] = useState(""); + const [prenom, setPrenom] = useState(""); + const [idPromo, setIdPromo] = useState(""); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([ + fetch(`/students/api/students/${numEtud}`), + fetch("/students/api/promotions"), + fetch("/notes/api/modules"), + fetch(`/mobility/api/mobilites?numEtud=${numEtud}`), + fetch(`/stages/api/stages?numEtud=${numEtud}`), + ]); + if (!sRes.ok) throw new Error("Élève introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + setNom(s.nom); + setPrenom(s.prenom); + setIdPromo(s.idPromo); + if (pRes.ok) setPromos(await pRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (mobRes.ok) { + const mobs: Mobilite[] = await mobRes.json(); + setMobWeeks( + mobs.filter((m) => m.status === "validated").reduce( + (s, m) => s + m.duree, + 0, + ), + ); + } + if (stRes.ok) { + const stages: Stage[] = await stRes.json(); + setStageWeeks(stages.reduce((s, st) => s + st.duree, 0)); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, [numEtud]); + + async function saveInfos() { + if (!student) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: nom.trim(), + prenom: prenom.trim(), + idPromo, + }), + }); + if (!res.ok) throw new Error("Modification échouée"); + const updated: Student = await res.json(); + setStudent(updated); + setSaveMsg("Informations enregistrées."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteStudent() { + if (!confirm(`Supprimer définitivement l'élève #${numEtud} ?`)) return; + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/students/consult"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + if (loading) { + return ( +
+

Chargement…

+
+ ); + } + + if (error && !student) { + return ( +
+

{error}

+
+ ); + } + + if (!student) return null; + + return ( +
+ + ← Retour à la liste + + +

+ Édition – {student.prenom} {student.nom} +

+ + {/* Info bar */} +
+ {student.numEtud} + {student.idPromo} + {anneeLabel(student.idPromo)} +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Informations générales */} +
+

Informations générales

+
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+ + setPrenom((e.target as HTMLInputElement).value)} + /> +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {/* Section 2: Notes */} +
+

Notes

+
+ + Récap complet des notes et moyennes + + + Voir les notes + +
+
+ + {/* Section 3: Mobilités */} +
+

Mobilités

+
+ + = 12 ? "#22c55e" : "#dc2626", + }} + > + {mobWeeks}/12 semaines validées + + + + Consulter + +
+
+ + {/* Section 4: Stages */} +
+

Stages

+
+ + = 40 ? "#22c55e" : "#dc2626", + }} + > + {stageWeeks}/40 semaines + + + + Consulter + +
+
+
+ ); +} diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 6e21876..2a20255 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -1,111 +1,175 @@ // @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; -import { Signal, useSignal } from "@preact/signals"; +import { useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; -/** - * Create a new handler for file change that displays - * messages in statusMessage and gets file data in fileData. - * @param statusMessage The status message signal. - * @param fileData The file data signal. - * @returns The file change handler. - */ -function getFileChangeHandler( - statusMessage: Signal, - fileData: Signal, -): (event: Event) => void { - /** - * Handle file change. - * @param event The file change event. - */ - return (event: Event) => { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - fileData.value = input.files[0]; - statusMessage.value = `File selected: ${input.files[0].name}`; - } else { - fileData.value = null; - statusMessage.value = "No file selected"; - } - }; -} +export default function UploadStudents() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const importResult = useSignal(null); + const inputRef = useRef(null); -/** - * Create a new handler that sends data file to server. - * @param statusMessage The status message signal. - * @param fileData The file data signal. - * @returns The file confirmation handler. - */ -function getUploadConfirmationFunction( - statusMessage: Signal, - fileData: Signal, -): () => void { - /** - * Add students to database. - * @returns Confirm upload of students. - */ - return () => { - if (!fileData.value) { - statusMessage.value = "Please select a file before confirming upload."; + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; return; } + file.value = f; + error.value = null; + importResult.value = null; + } - const reader = new FileReader(); + function onDragOver(e: DragEvent) { + e.preventDefault(); + dragging.value = true; + } - /** - * Send all data to the server. - * @param event The finished progress event. - */ - reader.onload = async (event: ProgressEvent) => { - const arrayBuffer = event.target!.result as ArrayBuffer; + function onDragLeave() { + dragging.value = false; + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + } + + function onInputChange(e: Event) { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + } + + async function doImport() { + if (!file.value) return; + uploading.value = true; + error.value = null; + importResult.value = null; + + try { + const arrayBuffer = await file.value.arrayBuffer(); const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let allOK = true; + let added = 0; + let errors = 0; + const details: ImportDetail[] = []; for (const sheetName of workbook.SheetNames) { const sheet = workbook.Sheets[sheetName]; - const data = XLSX.utils.sheet_to_json(sheet, { - header: ["userId", "lastName", "firstName", "mail"], - range: 1, + const rows = XLSX.utils.sheet_to_json<{ + nom: string; + prenom: string; + numEtud: number; + idPromo: string; + }>(sheet, { + header: ["nom", "prenom", "numEtud", "idPromo"], + range: 2, }); - const response = await fetch("/students/api/students", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ promoName: sheetName, data }), - }); - - if (!response.ok) { - allOK = false; + for (const row of rows) { + const res = await fetch("/students/api/students", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(row), + }); + if (res.ok) { + added++; + details.push({ + type: "change", + message: + `${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`, + }); + } else { + errors++; + const body = await res.json().catch(() => ({})); + details.push({ + type: "error", + message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`, + }); + } } } - statusMessage.value = allOK - ? "Failed to insert all data." - : "Data uploaded and inserted successfully!"; - }; + importResult.value = { + added, + modified: 0, + ignored: 0, + errors, + details, + }; + } catch { + error.value = "Erreur lors de la lecture du fichier."; + } finally { + uploading.value = false; + } + } - /** - * Display error message if any. - */ - reader.onerror = () => { - statusMessage.value = "Error reading the file."; - }; - - reader.readAsArrayBuffer(fileData.value); - }; -} - -export default function UploadStudents() { - const statusMessage = useSignal(""); - const fileData = useSignal(null); - - const handleFileChange = getFileChangeHandler(statusMessage, fileData); - const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData); + function downloadTemplate() { + globalThis.open("/templates/modele_etudiants.xlsx", "_blank"); + } return ( - <> - - -

{statusMessage.value}

- +
+ + +
inputRef.current?.click()} + > + + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + + {importResult.value && ( + (importResult.value = null)} + /> + )} + +
+ + +
+ +

+ Format : Nom | Prenom |{" "} + Numero-etudiant | Promotion +

+
); } diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 13bafe9..9a503f6 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "Students", icon: "badge", pages: { - index: "Homepage", - upload: "Upload students", - consult: "Consult students", + index: "Accueil", + consult: "Élèves", + upload: "Import xlsx", }, - adminOnly: ["upload", "consult"], + adminOnly: ["consult", "upload"], + employeeOnly: true, hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/[slug].tsx b/routes/(apps)/students/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/students/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/students/api/promotions.ts b/routes/(apps)/students/api/promotions.ts new file mode 100644 index 0000000..8e87820 --- /dev/null +++ b/routes/(apps)/students/api/promotions.ts @@ -0,0 +1,49 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { promotions } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; + +export const handler: Handlers = { + // #13 GET /promotions + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(JSON.stringify([]), { + headers: { "content-type": "application/json" }, + }); + } + + const rows = await db.select().from(promotions); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + + // #14 POST /promotions + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + const body: { idPromo: string; annee: string } = await request.json(); + + if (!body.idPromo || !body.annee) { + return new Response(null, { status: 400 }); + } + + const [created] = await db + .insert(promotions) + .values({ id: body.idPromo, annee: body.annee }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/students/api/promotions/[idPromo].ts b/routes/(apps)/students/api/promotions/[idPromo].ts new file mode 100644 index 0000000..53f1d95 --- /dev/null +++ b/routes/(apps)/students/api/promotions/[idPromo].ts @@ -0,0 +1,173 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { + ajustements, + enseignements, + modules, + notes, + promotions, + students, + ueModules, + ues, +} from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // #15 GET /promotions/{idPromo} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const promo = await db + .select() + .from(promotions) + .where(eq(promotions.id, context.params.idPromo)) + .then((rows) => rows[0] ?? null); + + if (!promo) return NOT_FOUND(); + + return new Response(JSON.stringify(promo), { + headers: { "content-type": "application/json" }, + }); + }, + + // #16 PUT /promotions/{idPromo} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const body: { annee: string } = await request.json(); + + const [updated] = await db + .update(promotions) + .set({ annee: body.annee }) + .where(eq(promotions.id, context.params.idPromo)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // #17 DELETE /promotions/{idPromo} + // Blocked if students are still assigned (409). + // Cascade: deletes linked ue_modules, enseignements, and orphaned + // modules (+ their notes) & UEs (+ their ajustements). + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idPromo = context.params.idPromo; + + const promo = await db + .select() + .from(promotions) + .where(eq(promotions.id, idPromo)) + .then((r) => r[0] ?? null); + + if (!promo) return NOT_FOUND(); + + // Block deletion if students are still assigned + const assignedStudents = await db + .select() + .from(students) + .where(eq(students.idPromo, idPromo)) + .then((r) => r.length); + + if (assignedStudents > 0) { + return new Response( + JSON.stringify({ + error: + `Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + } + + await db.transaction(async (tx) => { + // Collect linked module IDs and UE IDs before deleting junction rows + const linkedUeModules = await tx + .select({ idModule: ueModules.idModule, idUE: ueModules.idUE }) + .from(ueModules) + .where(eq(ueModules.idPromo, idPromo)); + + const linkedEns = await tx + .select({ idModule: enseignements.idModule }) + .from(enseignements) + .where(eq(enseignements.idPromo, idPromo)); + + const moduleIds = [ + ...new Set([ + ...linkedUeModules.map((um) => um.idModule), + ...linkedEns.map((e) => e.idModule), + ]), + ]; + const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))]; + + // Delete junction rows that directly reference this promo + await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo)); + await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo)); + + // Delete orphaned modules (not used by another promo) and their notes + for (const modId of moduleIds) { + const stillInUeModules = await tx + .select() + .from(ueModules) + .where(eq(ueModules.idModule, modId)) + .then((r) => r.length > 0); + const stillInEns = await tx + .select() + .from(enseignements) + .where(eq(enseignements.idModule, modId)) + .then((r) => r.length > 0); + + if (!stillInUeModules && !stillInEns) { + await tx.delete(notes).where(eq(notes.idModule, modId)); + await tx.delete(modules).where(eq(modules.id, modId)); + } + } + + // Delete orphaned UEs (not used by another promo) and their ajustements + for (const ueId of ueIds) { + const stillUsed = await tx + .select() + .from(ueModules) + .where(eq(ueModules.idUE, ueId)) + .then((r) => r.length > 0); + + if (!stillUsed) { + await tx.delete(ajustements).where(eq(ajustements.idUE, ueId)); + await tx.delete(ues).where(eq(ues.id, ueId)); + } + } + + // Delete the promotion + await tx.delete(promotions).where(eq(promotions.id, idPromo)); + }); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index 157299f..e2e5d38 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -1,151 +1,73 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; -import connect from "$root/databases/connect.ts"; +import { db } from "$root/databases/db.ts"; +import { students } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { Database } from "@db/sqlite"; - -/** - * Gets itself from the database. - * @param database The database connection - * @param userId The user ID. - * @returns Itself from the database. - */ -function getItself( - database: Database, - userId: string, -): { student: Student | null; promo: Promotion | null } { - const studentQuery = "select * from students where userId = ?"; - const student: Student | undefined = database.prepare(studentQuery).get( - userId, - ); - - if (!student) { - return { student: null, promo: null }; - } - - const promoQuery = "select * from promotions where id = ?"; - const promo: Promotion | undefined = database.prepare(promoQuery).get( - student.promotionId, - ); - - return { student, promo: promo ?? null }; -} - -/** - * Gets itself from the database. - * @param database The database connexion - * @param userId The user ID. - * @returns Itself from the database. - */ -function getAll( - database: Database, -): { students: Student[]; promos: Promotion[] } { - const studentsQuery = ` - select userId, firstName, lastName, mail, promotionId - from students inner join promotions - on students.promotionId = promotions.id - where promotions.current < 6`; - const students: Student[] = database.prepare(studentsQuery).all(); - - const promosQuery = "select * from promotions where promotions.current < 6"; - const promos: Promotion[] | undefined = database.prepare(promosQuery).all(); - - return { students, promos }; -} - -/** - * Add users to the database. - * @param database The database connexion - * @param students The students to add - * @param promoId The promotion id. - */ -function addStudents(database: Database, students: Student[], promoId: string) { - const query = ` - INSERT INTO students - (userId, firstName, lastName, mail, promotionId) - VALUES (?, ?, ?, ?, ?)`; - - const statement = database.prepare(query); - - for (const student of students) { - statement.run( - student.userId, - student.firstName, - student.lastName, - student.mail, - promoId, - ); - } -} +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - /** - * The students the user can see. - * @param _request The HTTP request. - * @param _context The context with authenticated state. - * @returns All students our user can see. - */ - // deno-lint-ignore require-await + // #7 GET /students async GET( - _request: Request, + request: Request, context: FreshContext, ): Promise { - using connection = connect("students"); - const database = connection.database; - - if (context.state.session.eduPersonPrimaryAffiliation == "student") { - return new Response( - JSON.stringify(getItself(database, context.state.session.uid)), - { - headers: { - "content-type": "application/json", - }, - }, - ); + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(JSON.stringify([]), { + headers: { "content-type": "application/json" }, + }); } - return new Response( - JSON.stringify(getAll(database)), - { - headers: { - "content-type": "application/json", - }, - }, - ); + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + + const rows = idPromo + ? await db.select().from(students).where(eq(students.idPromo, idPromo)) + : await db.select().from(students); + + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); }, - /** - * Add students in the database. - * @param request The HTTP request. - * @param _context The Fresh context. - * @returns HTTP 201 on successful insert. - */ + + // #8 POST /students async POST( request: Request, - _context: FreshContext, + context: FreshContext, ): Promise { - const { students, promo }: { students: Student[]; promo: string } = - await request.json(); + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } - if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(students)) { + const body: { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; + } = await request.json(); + + if (!body.nom || !body.prenom) { return new Response(null, { status: 400 }); } - using connection = connect("students"); - const database = connection.database; + const values: { + numEtud?: number; + nom: string; + prenom: string; + idPromo?: string; + } = { + nom: body.nom, + prenom: body.prenom, + }; + if (body.numEtud) values.numEtud = body.numEtud; + if (body.idPromo) values.idPromo = body.idPromo; - const { endyear, current } = promo.match( - /^(?\d{4})-(?\d)A$/, - )?.groups!; + const [created] = await db + .insert(students) + .values(values) + .returning(); - database.prepare( - "insert or ignore into promotions (endyear, current) values (?, ?)", - ).run(endyear, current); - - const { id: promoId }: { id: string } = database - .prepare("select id from promotions where endyear = ? and current = ?") - .get(endyear, current)!; - - addStudents(database, students, promoId); - - return new Response(null, { status: 201 }); + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); }, }; diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts new file mode 100644 index 0000000..6d2c0e6 --- /dev/null +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -0,0 +1,113 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { + ajustements, + mobilites, + notes, + stages, + students, +} from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // #10 GET /students/{numEtud} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + const student = await db + .select() + .from(students) + .where(eq(students.numEtud, numEtud)) + .then((rows) => rows[0] ?? null); + + if (!student) return NOT_FOUND(); + + return new Response(JSON.stringify(student), { + headers: { "content-type": "application/json" }, + }); + }, + + // #11 PUT /students/{numEtud} + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + const body: { nom?: string; prenom?: string; idPromo?: string } = + await request.json(); + + const set: { nom?: string; prenom?: string; idPromo?: string } = {}; + if (body.nom !== undefined) set.nom = body.nom; + if (body.prenom !== undefined) set.prenom = body.prenom; + if (body.idPromo !== undefined) set.idPromo = body.idPromo; + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(students) + .set(set) + .where(eq(students.numEtud, numEtud)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // #12 DELETE /students/{numEtud} + // Cascade: deletes notes, ajustements, mobilites, stages for this student. + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const numEtud = Number(context.params.numEtud); + + const student = await db + .select() + .from(students) + .where(eq(students.numEtud, numEtud)) + .then((r) => r[0] ?? null); + + if (!student) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(notes).where(eq(notes.numEtud, numEtud)); + await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud)); + await tx.delete(mobilites).where(eq(mobilites.numEtud, numEtud)); + await tx.delete(stages).where(eq(stages.numEtud, numEtud)); + await tx.delete(students).where(eq(students.numEtud, numEtud)); + }); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/students/api/students/import-csv.ts b/routes/(apps)/students/api/students/import-csv.ts new file mode 100644 index 0000000..1e233a0 --- /dev/null +++ b/routes/(apps)/students/api/students/import-csv.ts @@ -0,0 +1,64 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { students } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; + +// #9 POST /students/import-csv +export const handler: Handlers = { + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const idPromo = formData.get("idPromo") as string | null; + + if (!file || !idPromo) { + return new Response(null, { status: 400 }); + } + + const text = await file.text(); + const lines = text.trim().split("\n"); + + let imported = 0; + const errors: { line: number; message: string }[] = []; + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const cols = lines[i].split(",").map((c) => c.trim()); + + const [numEtudStr, nom, prenom] = cols; + + if (!numEtudStr) { + errors.push({ line: lineNum, message: "Numéro étudiant manquant" }); + continue; + } + + const numEtud = Number(numEtudStr); + if (isNaN(numEtud)) { + errors.push({ line: lineNum, message: "Numéro étudiant invalide" }); + continue; + } + + if (!nom || !prenom) { + errors.push({ line: lineNum, message: "Nom ou prénom manquant" }); + continue; + } + + await db + .insert(students) + .values({ nom, prenom, idPromo }) + .onConflictDoNothing(); + + imported++; + } + + return new Response(JSON.stringify({ imported, errors }), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/students/edit/[numEtud].tsx b/routes/(apps)/students/edit/[numEtud].tsx new file mode 100644 index 0000000..e88ff1b --- /dev/null +++ b/routes/(apps)/students/edit/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditStudents from "../(_islands)/EditStudents.tsx"; + +// deno-lint-ignore require-await +export default async function EditPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index b685c5c..2adaaa4 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -8,13 +8,9 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { - return ( - <> -

Consult students

- - - ); + return ; } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index 2f36f6d..ca1b847 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -9,12 +9,13 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { return ( - <> -

Upload Students

+
+

Importer des Élèves

- +
); } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index 78931b5..c696b94 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -4,16 +4,44 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + return ( - <> -

Welcome {context.state.session?.givenName}!

-

Your amU identity

- - +
+

Étudiants

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee && ( +

+ Consultez la{" "} + + liste des élèves + {" "} + ou gérez les{" "} + + promotions + + . +

+ )} +
); } diff --git a/routes/_app.tsx b/routes/_app.tsx index 60d03a9..77ba7c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -26,8 +26,10 @@ export default async function App( /> - - + + + +
diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 01b449e..2588e7b 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -10,6 +10,7 @@ const PUBLIC_ROUTES = [ "/about", "/partials/about", "/contact", + "/dev-login", ]; const jwtKeyCache: Record = {}; diff --git a/routes/apps.tsx b/routes/apps.tsx index d64cabb..067798d 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -44,9 +44,20 @@ export default async function Apps( _request: Request, context: FreshContext>, ) { + let visibleApps = context.data; + + if ( + context.state.isAuthenticated && + context.state.session.eduPersonPrimaryAffiliation === "student" + ) { + visibleApps = Object.fromEntries( + Object.entries(context.data).filter(([_, app]) => !app.employeeOnly), + ); + } + return ( <> - + ); } diff --git a/routes/dev-login.ts b/routes/dev-login.ts new file mode 100644 index 0000000..22058a7 --- /dev/null +++ b/routes/dev-login.ts @@ -0,0 +1,80 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts"; +import { createJwt } from "@popov/jwt"; +import { setCookie } from "$std/http/cookie.ts"; +import { getKey } from "$root/routes/_middleware.ts"; + +function makeFakeUser( + role: "employee" | "student", + numEtud?: string, +): CasContent { + if (role === "student" && numEtud) { + return { + amuCampus: "local", + amuComposante: "local", + amuDateValidation: "", + coGroup: "", + eduPersonPrimaryAffiliation: "student", + eduPersonPrincipalName: `${numEtud}@local`, + mail: `${numEtud}@local`, + displayName: `Etudiant ${numEtud}`, + givenName: "", + memberOf: [], + sn: "", + supannCivilite: "", + supannEntiteAffectation: "", + supannEtuAnneeInscription: "", + supannEtuEtape: "", + uid: `e${numEtud}`, + }; + } + return { + amuCampus: "local", + amuComposante: "local", + amuDateValidation: "", + coGroup: "", + eduPersonPrimaryAffiliation: "employee", + eduPersonPrincipalName: "admin@local", + mail: "admin@local", + displayName: "Admin Local", + givenName: "Admin", + memberOf: [], + sn: "Local", + supannCivilite: "", + supannEntiteAffectation: "", + supannEtuAnneeInscription: "", + supannEtuEtape: "", + uid: "admin-local", + }; +} + +export const handler: Handlers = { + async GET(request: Request, _context: FreshContext) { + if (Deno.env.get("LOCAL") !== "true") { + return new Response("Not available outside LOCAL mode.", { status: 403 }); + } + + const url = new URL(request.url); + const role = url.searchParams.get("role") === "student" + ? "student" + : "employee"; + const numEtud = url.searchParams.get("numEtud") ?? undefined; + const user = makeFakeUser(role, numEtud); + + const now = Math.floor(Date.now() / 1000); + const payload: LoginJWT = { + iss: "PolyMPR", + iat: now, + exp: now + 0xe10, + aud: "PolyMPR", + user, + }; + + const token = await createJwt(payload, getKey(user.uid)); + const headers = new Headers(); + setCookie(headers, { name: "sessionToken", value: token }); + headers.set("Location", "/apps"); + + return new Response(null, { status: 302, headers }); + }, +}; diff --git a/routes/index.tsx b/routes/index.tsx index f92dc1b..b16caea 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,13 +1,28 @@ -import { FreshContext } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; -// deno-lint-ignore require-await -export default async function Home(_request: Request, _context: FreshContext) { +export const handler: Handlers = { + GET(_request: Request, context: FreshContext) { + if (context.state.isAuthenticated) { + return new Response(null, { + status: 302, + headers: { Location: "/apps" }, + }); + } + return context.render(); + }, +}; + +export default function Home() { return ( <>

PolyMPR

The ultimate HR platform

+

+ Se connecter +

); } diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts new file mode 100644 index 0000000..5245a7c --- /dev/null +++ b/scripts/generate-templates.ts @@ -0,0 +1,83 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; + +// --- Template 1: Students --- +{ + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([ + [ + null, + null, + null, + "Promotion peut etre vide mais doit prealablement Exister", + ], + ["Nom", "Prenom", "Numero-etudiant", "Promotion"], + ["NOM", "PRENOM", 12345678, "3AFISE24-25"], + ]); + XLSX.utils.book_append_sheet(wb, ws, "Eleves"); + XLSX.writeFile(wb, "static/templates/modele_etudiants.xlsx"); + console.log("Created static/templates/modele_etudiants.xlsx"); +} + +// --- Template 2: Notes --- +{ + const headers = [ + null, + null, + "MOD01 - Module 1", + "MOD02 - Module 2", + "MOD03 - Module 3", + ]; + const coeffs = [null, null, 2, 3, 2]; + const row1 = ["NOM", "PRENOM", 12, 15.5, 14]; + const row2 = ["DUPONT", "JEAN", 8, 10, 16.5]; + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([headers, coeffs, row1, row2]); + XLSX.utils.book_append_sheet(wb, ws, "Session 1"); + XLSX.writeFile(wb, "static/templates/modele_notes.xlsx"); + console.log("Created static/templates/modele_notes.xlsx"); +} + +// --- Template 3: Maquette --- +{ + const data = [ + ["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."], + [ + "Description des UE du diplome", + null, + null, + null, + null, + null, + "Nombre d'heures", + ], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\n ECTS", + "Coeff.", + "CM", + "TD", + "TP", + ], + ["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"], + ["SEM 5", null, null, null, 30], + ["UE", "CODE_UE1", "Nom de l'UE 1", null, 6], + [null, "MOD01", null, "Module 1", null, 2, 10, 10, 10], + [null, "MOD02", null, "Module 2", null, 2, 10, 10, 10], + [null, "MOD03", null, "Module 3", null, 2, 10, 10, 10], + [], + ["UE", "CODE_UE2", "Nom de l'UE 2", null, 4], + [null, "MOD04", null, "Module 4", null, 2, 10, 10, 10], + [null, "MOD05", null, "Module 5", null, 2, 10, 10, 10], + ]; + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, "Maquette"); + XLSX.writeFile(wb, "static/templates/modele_maquette.xlsx"); + console.log("Created static/templates/modele_maquette.xlsx"); +} diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts new file mode 100644 index 0000000..b96865f --- /dev/null +++ b/scripts/inspect-maquette.ts @@ -0,0 +1,29 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; + +for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { + console.log(`\n=== ${file} ===`); + const wb = XLSX.read(Deno.readFileSync(`Excels/${file}`), { type: "array" }); + console.log(`Sheets: ${wb.SheetNames.join(", ")}`); + + for (const sheetName of wb.SheetNames) { + console.log(`\n--- Sheet: ${sheetName} ---`); + const sheet = wb.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + // Print first 5 cols of each row, mark rows that look like year/semester headers + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row || row.length === 0) continue; + const col0 = row[0] != null ? String(row[0]).trim() : ""; + // Show rows that are structural (year, semester, UE headers) + if (col0 || (row[1] != null && String(row[1]).trim())) { + const preview = row.slice(0, 6).map((c) => + c != null ? String(c).substring(0, 25) : "" + ).join(" | "); + console.log(` [${i}] ${preview}`); + } + } + } +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..ab0e69a --- /dev/null +++ b/shell.nix @@ -0,0 +1,23 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + name = "polympr-dev"; + nativeBuildInputs = [ + pkgs.deno + pkgs.patchelf + pkgs.tea + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" + # Find the dynamic linker + export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker) + echo "Welcome to PolyMPR development shell!" + echo "Use 'deno task compile' to build the CLI." + echo "If on NixOS, it will be automatically patched." + ''; +} diff --git a/static/styles/main.css b/static/styles/main.css index ce2282a..9fbd74e 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -29,6 +29,10 @@ font-family: var(--font-family-text); } +html { + font-size: 130%; /* scale up from browser default 16px → ~20.8px */ +} + html, body { margin: 0; padding: 0; diff --git a/static/styles/ui.css b/static/styles/ui.css new file mode 100644 index 0000000..6583f3c --- /dev/null +++ b/static/styles/ui.css @@ -0,0 +1,1211 @@ +/* ui.css — Shared UI components for PolyMPR app pages */ + +/* ------------------------------------------------------- + Page layout +------------------------------------------------------- */ + +.page-content { + padding: 1.5rem; +} + +.page-title { + font-size: 1.2rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.75rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +/* ------------------------------------------------------- + Filters bar +------------------------------------------------------- */ + +.filters { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.filter-input, +.filter-select { + padding: 0.3rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.8rem; + font-family: inherit; + min-width: 8rem; + box-sizing: border-box; +} + +.form-field .filter-select { + width: 100%; + min-width: 0; +} + +.filter-input:focus, +.filter-select:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Buttons +------------------------------------------------------- */ + +.btn { + padding: 0.3rem 0.75rem; + border-radius: 3px; + font-size: 0.8rem; + font-family: inherit; + font-weight: var(--font-weight-bold); + cursor: pointer; + border: 1px solid; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; + line-height: 1.4; + background: transparent; + transition: background 100ms, color 100ms; +} + +.btn::before { + all: unset; +} + +.btn-primary { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.btn-primary:hover { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); +} + +.btn-secondary { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.btn-secondary:hover { + border-color: light-dark( + var(--light-foreground-dim), + var(--dark-foreground-dim) + ); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.btn-danger { + border-color: #933; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.btn-danger:hover { + background: #933; + color: white; +} + +.btn-sm { + padding: 0.15rem 0.5rem; + font-size: 0.75rem; +} + +/* ------------------------------------------------------- + Data table +------------------------------------------------------- */ + +.data-table-wrap { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + overflow: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.data-table th { + padding: 0.5rem 1rem; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + text-align: left; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table td { + padding: 0.55rem 1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:nth-child(even) td { + background: light-dark(#f5f4ff, #141229); +} + +.data-table .col-promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + font-size: 0.75rem; +} + +.data-table .col-dim { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.75rem; +} + +.data-table .col-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} + +/* ------------------------------------------------------- + UE card (student notes view) +------------------------------------------------------- */ + +.ue-card { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + overflow: hidden; + position: relative; + padding-left: 3px; +} + +.ue-card::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + transform: none; + transition: none; + border-radius: 0; +} + +.ue-card-header { + padding: 0.65rem 1rem 0.5rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-card-title { + font-weight: var(--font-weight-bold); + font-size: 0.85rem; + margin: 0; +} + +.ue-card-avg { + font-size: 0.7rem; + margin: 0.2rem 0 0; +} + +.ue-card-avg.avg-good { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.ue-card-avg.avg-warn { + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.ue-module-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.45rem 1rem 0.45rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-module-row:last-child { + border-bottom: none; +} + +.ue-module-name { + font-size: 0.8rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Score chip +------------------------------------------------------- */ + +.score-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5rem; + padding: 0.15rem 0.5rem; + border-radius: 12px; + border: 1px solid; + font-size: 0.75rem; + font-weight: var(--font-weight-bold); + background: light-dark(white, #1a172d); +} + +.score-chip.score-good { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.score-chip.score-warn { + border-color: light-dark( + var(--light-strong-color), + var(--dark-strong-color) + ); + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.score-chip.score-none { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Tabs +------------------------------------------------------- */ + +.tabs { + display: flex; + gap: 0.6rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.tab-btn { + padding: 0.35rem 0.9rem; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.tab-btn::before { + all: unset; +} + +.tab-btn.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + border-bottom-width: 2px; +} + +/* ------------------------------------------------------- + Status states +------------------------------------------------------- */ + +.state-loading, +.state-empty { + padding: 2.5rem; + text-align: center; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.9rem; +} + +.state-error { + padding: 1rem; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); + font-size: 0.85rem; + border: 1px solid #933; + border-radius: 4px; + background: light-dark(#fff0f0, #1a1010); + margin-bottom: 1rem; +} + +/* ------------------------------------------------------- + Inline form row (for inline add/edit) +------------------------------------------------------- */ + +.form-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.form-input { + padding: 0.35rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.82rem; + font-family: inherit; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +.form-input:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Toolbar (title + action button) +------------------------------------------------------- */ + +.toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; +} + +/* ------------------------------------------------------- + Bulk actions bar +------------------------------------------------------- */ + +.bulk-bar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + border-radius: 6px; + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-size: 0.82rem; + flex-wrap: wrap; +} + +.bulk-count { + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.bulk-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex-wrap: wrap; +} + +.bulk-bar .filter-select { + color: light-dark( + var(--light-foreground-color), + var(--dark-foreground-color) + ); + font-size: 0.78rem; +} + +.row-selected { + background: light-dark( + color-mix(in srgb, var(--light-accent-color) 8%, transparent), + color-mix(in srgb, var(--dark-accent-color) 12%, transparent) + ); +} + +/* ------------------------------------------------------- + Chips: perm, role, promo, module +------------------------------------------------------- */ + +.perm-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.68rem; + font-family: monospace; + margin: 0.1rem; +} + +.role-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + margin: 0.1rem; +} + +.promo-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid #b8820a; + color: #d4a017; + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.filiere-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.module-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.7rem; + font-family: monospace; +} + +.numEtud-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Permission toggle cards (role management) +------------------------------------------------------- */ + +.perm-header-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.85rem; + margin-bottom: 1.25rem; + background: light-dark(#f5f4ff, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; +} + +.perm-toggle-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} + +.perm-toggle-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + cursor: pointer; + gap: 0.5rem; +} + +.perm-toggle-card.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + background: light-dark(#f0fff4, #0d1f12); +} + +.perm-toggle-label { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.perm-toggle-id { + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.perm-toggle-nom { + font-size: 0.78rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* Simple toggle switch */ +.toggle-switch { + position: relative; + width: 2.4rem; + height: 1.3rem; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: light-dark(#ccc, #444); + border-radius: 1rem; + transition: background 150ms; +} + +.toggle-slider::before { + content: ""; + position: absolute; + width: 1rem; + height: 1rem; + left: 0.15rem; + top: 0.15rem; + background: white; + border-radius: 50%; + transition: transform 150ms; +} + +.toggle-switch input:checked + .toggle-slider { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(1.1rem); +} + +/* ------------------------------------------------------- + UE split layout +------------------------------------------------------- */ + +.ue-split { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.ue-panel-left { + width: 270px; + flex-shrink: 0; +} + +.ue-panel-right { + flex: 1; + min-width: 0; +} + +.panel-box { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 0.75rem; +} + +.panel-box-title { + font-size: 0.8rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.6rem; +} + +.ue-list-item { + padding: 0.45rem 0.6rem; + cursor: pointer; + border-radius: 3px; + font-size: 0.82rem; + border: 1px solid transparent; + margin-bottom: 0.2rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.ue-list-item:hover { + background: light-dark(#f0efff, #1a172d); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.ue-list-item.active { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Pill buttons (PromoBuilder) +------------------------------------------------------- */ + +.pill-group { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.pill-btn { + padding: 0.3rem 0.8rem; + border-radius: 20px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.pill-btn::before { + all: unset; +} + +.pill-btn.active { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* PromoBuilder box */ +.promo-builder { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.promo-builder-title { + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.promo-builder-subtitle { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.85rem; +} + +.promo-builder-row { + display: flex; + gap: 1.5rem; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.promo-builder-field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.promo-builder-field label { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.promo-id-preview { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.75rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 3px; + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + min-width: 10rem; + font-family: monospace; +} + +/* ------------------------------------------------------- + Edit student sections +------------------------------------------------------- */ + +.edit-section { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; +} + +.edit-section-title { + font-size: 0.88rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.edit-section-subtitle { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.8rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.form-field label { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.info-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.82rem; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + text-decoration: none; + margin-bottom: 0.5rem; +} + +.back-link:hover { + text-decoration: underline; +} + +/* ------------------------------------------------------- + File drop zone (import pages) +------------------------------------------------------- */ + +.drop-zone { + border: 2px dashed + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + background: light-dark(white, #141228); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 3rem 2rem; + cursor: pointer; + transition: border-color 150ms, background 150ms; + margin-bottom: 1.25rem; + text-align: center; +} + +.drop-zone:hover, +.drop-zone.dragging { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: light-dark(#f0fff4, #0a1a10); +} + +.drop-zone-icon { + font-size: 2.4rem; + line-height: 1; + opacity: 0.8; +} + +.drop-zone-text { + font-size: 0.88rem; + font-weight: var(--font-weight-bold); +} + +.drop-zone-hint { + font-size: 0.75rem; + font-family: monospace; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.drop-zone-file { + font-size: 0.78rem; + font-family: monospace; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); +} + +.upload-actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.create-promo-inline { + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px dashed + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; +} + +.upload-format { + font-size: 0.72rem; + font-family: monospace; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* Info note box */ +.info-note { + padding: 0.75rem 1rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + background: light-dark(#f0fff4, #0a1a10); + margin-top: 1.5rem; + font-size: 0.82rem; +} + +/* ------------------------------------------------------- + Note recap chips & rows +------------------------------------------------------- */ +.note-chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 10px; + border: 1px solid currentColor; + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + white-space: nowrap; +} + +.note-chip--ok { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.note-chip--fail { + color: light-dark(#dc2626, #f87171); +} + +.note-chip--none { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.note-chip--promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: transparent; +} + +.note-chip--ajust { + color: #f59e0b; +} + +.note-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + flex-wrap: wrap; +} + +.note-row-label { + flex: 1; + min-width: 10rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.note-row-chip { + font-size: 0.68rem; + padding: 0.1rem 0.4rem; +} + +.note-row-coef { + font-size: 0.75rem; + white-space: nowrap; +} + +.ajust-section { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ajust-title { + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.ajust-hint { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.5rem; +} + +/* ------------------------------------------------------- + (end note recap) +------------------------------------------------------- */ + +/* ------------------------------------------------------- + Modal overlay +------------------------------------------------------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-box { + background: light-dark(white, #1a172d); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + padding: 1.25rem; + min-width: 22rem; + max-width: 90vw; +} + +.modal-title { + font-size: 0.95rem; + font-weight: var(--font-weight-bold); + margin: 0 0 1rem; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.info-note-dim { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin-top: 0.25rem; +} + +/* ------------------------------------------------------- + Import result popup +------------------------------------------------------- */ + +.import-popup-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.import-popup { + background: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + border: 1px solid + light-dark(var(--light-border-color), var(--dark-border-color)); + border-radius: 10px; + padding: 1.5rem 2rem; + min-width: 28rem; + max-width: 40rem; + max-height: 80vh; + overflow-y: auto; +} + +.import-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.import-popup-title { + font-size: 1.1rem; + font-weight: var(--font-weight-bold); + margin: 0; +} + +.import-popup-badge { + font-size: 0.78rem; + font-weight: 600; + padding: 0.25rem 0.75rem; + border-radius: 4px; + border: 1px solid; +} + +.badge-error { + color: #f5a623; + border-color: #f5a623; +} + +.badge-success { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.import-popup-stats { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1.25rem; +} + +.import-stat-row { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.import-stat-label { + min-width: 6rem; + font-size: 0.85rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.import-stat-value { + font-size: 0.85rem; + font-family: monospace; + padding: 0.2rem 0.6rem; + border-radius: 4px; + border: 1px solid; + min-width: 8rem; +} + +.stat-added { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.stat-modified { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.stat-ignored { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + border-color: light-dark(var(--light-border-color), var(--dark-border-color)); +} + +.stat-errors { + color: #f5a623; + border-color: #f5a623; +} + +.import-popup-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.import-popup-details { + border-top: 1px solid + light-dark(var(--light-border-color), var(--dark-border-color)); + padding-top: 0.75rem; + font-family: monospace; + font-size: 0.75rem; + max-height: 12rem; + overflow-y: auto; +} + +.import-detail-change { + margin: 0.15rem 0; + color: light-dark( + var(--light-foreground-color), + var(--dark-foreground-color) + ); +} + +.import-detail-error { + margin: 0.15rem 0; + color: #f5a623; +} diff --git a/static/templates/modele_etudiants.xlsx b/static/templates/modele_etudiants.xlsx new file mode 100644 index 0000000..65ddb68 Binary files /dev/null and b/static/templates/modele_etudiants.xlsx differ diff --git a/static/templates/modele_maquette.xlsx b/static/templates/modele_maquette.xlsx new file mode 100644 index 0000000..f326c5e Binary files /dev/null and b/static/templates/modele_maquette.xlsx differ diff --git a/static/templates/modele_notes.xlsx b/static/templates/modele_notes.xlsx new file mode 100644 index 0000000..55d9614 Binary files /dev/null and b/static/templates/modele_notes.xlsx differ diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..041e9a8 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,29 @@ +(function () { + const t = localStorage.getItem("theme"); + if (t) document.documentElement.style.colorScheme = t; + + document.addEventListener("click", function (e) { + const btn = e.target.closest("#theme-toggle"); + if (!btn) return; + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + const next = isDark ? "light" : "dark"; + document.documentElement.style.colorScheme = next; + localStorage.setItem("theme", next); + btn.querySelector("span").textContent = next === "dark" + ? "light_mode" + : "dark_mode"; + }); + + document.addEventListener("DOMContentLoaded", function () { + const btn = document.getElementById("theme-toggle"); + if (!btn) return; + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; + }); +})(); diff --git a/routes/(apps)/mobility/(_islands)/ImportFile.tsx b/tests/e2e/.gitkeep similarity index 100% rename from routes/(apps)/mobility/(_islands)/ImportFile.tsx rename to tests/e2e/.gitkeep diff --git a/tests/e2e/ajustements_test.ts b/tests/e2e/ajustements_test.ts new file mode 100644 index 0000000..2ca2ef7 --- /dev/null +++ b/tests/e2e/ajustements_test.ts @@ -0,0 +1,349 @@ +// E2E tests for /ajustements endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedAjustements, + seedPromotions, + seedStudents, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; +import { handler as ajustementHandler } from "$apps/notes/api/ajustements/[numEtud]/[idUE].ts"; +import { ajustements as ajustementsTable } from "$root/databases/schema.ts"; +import { testDb } from "../helpers/db_integration.ts"; + +// --- GET /ajustements --- + +Deno.test({ + name: "e2e ajustements: GET /ajustements returns all", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }]); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements?numEtud filters by student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + const [s2] = await seedStudents([{ + nom: "Martin", + prenom: "Alice", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([ + { numEtud: s1.numEtud, idUE: ue.id, valeur: 13.0 }, + { numEtud: s2.numEtud, idUE: ue.id, valeur: 15.0 }, + ]); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements", { numEtud: String(s1.numEtud) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements?numEtud=NaN returns 400", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements", { numEtud: "abc" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ajustements --- + +Deno.test({ + name: + "e2e ajustements: POST /ajustements creates ajustement (201) as employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Leroy", + prenom: "Paul", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { + numEtud: s.numEtud, + idUE: ue.id, + valeur: 14.5, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.numEtud); + assertEquals(body.valeur, 14.5); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: POST /ajustements 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { + numEtud: 1, + idUE: 1, + valeur: 10.0, + }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: POST /ajustements 400 on missing fields", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: 12345 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: + "e2e ajustements: GET /ajustements/:numEtud/:idUE returns correct ajustement (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1, s2] = await seedStudents([ + { nom: "Bernard", prenom: "Lucie", idPromo: "P1" }, + { nom: "Dupont", prenom: "Jean", idPromo: "P1" }, + ]); + const [ue1, ue2] = await seedUes([{ nom: "UE Maths" }, { nom: "UE Info" }]); + // Plusieurs lignes partageant numEtud=s1 — le handler doit discriminer par idUE + await seedAjustements([ + { numEtud: s1.numEtud, idUE: ue1.id, valeur: 16.0 }, + { numEtud: s1.numEtud, idUE: ue2.id, valeur: 8.0 }, + { numEtud: s2.numEtud, idUE: ue1.id, valeur: 12.0 }, + ]); + const res = await ajustementHandler.GET!( + makeGetRequest(`/ajustements/${s1.numEtud}/${ue1.id}`), + makeEmployeeContext({ + numEtud: String(s1.numEtud), + idUE: String(ue1.id), + }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.valeur, 16.0); + assertEquals(body.numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.GET!( + makeGetRequest("/ajustements/1/1"), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.GET!( + makeGetRequest("/ajustements/99999/99"), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: + "e2e ajustements: PUT /ajustements/:numEtud/:idUE updates only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Thomas", + prenom: "Eva", + idPromo: "P1", + }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Physique" }, { + nom: "UE Chimie", + }]); + // Deux ajustements pour le même étudiant — seul ue1 doit être modifié + await seedAjustements([ + { numEtud: s.numEtud, idUE: ue1.id, valeur: 10.0 }, + { numEtud: s.numEtud, idUE: ue2.id, valeur: 7.0 }, + ]); + const res = await ajustementHandler.PUT!( + makeJsonRequest(`/ajustements/${s.numEtud}/${ue1.id}`, "PUT", { + valeur: 19.0, + }), + makeEmployeeContext({ numEtud: String(s.numEtud), idUE: String(ue1.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.valeur, 19.0); + // ue2 doit rester intact + const unchanged = await testDb.select().from(ajustementsTable); + const ue2Row = unchanged.find((a) => a.idUE === ue2.id); + assertEquals(ue2Row?.valeur, 7.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.PUT!( + makeJsonRequest("/ajustements/1/1", "PUT", { valeur: 10.0 }), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.PUT!( + makeJsonRequest("/ajustements/99999/99", "PUT", { valeur: 10.0 }), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: + "e2e ajustements: DELETE /ajustements/:numEtud/:idUE deletes only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Petit", + prenom: "Hugo", + idPromo: "P1", + }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Chimie" }, { nom: "UE Bio" }]); + // Deux ajustements pour le même étudiant — seul ue1 doit être supprimé + await seedAjustements([ + { numEtud: s.numEtud, idUE: ue1.id, valeur: 11.0 }, + { numEtud: s.numEtud, idUE: ue2.id, valeur: 14.0 }, + ]); + const res = await ajustementHandler.DELETE!( + makeGetRequest(`/ajustements/${s.numEtud}/${ue1.id}`), + makeEmployeeContext({ numEtud: String(s.numEtud), idUE: String(ue1.id) }), + ); + assertEquals(res.status, 204); + // ue2 doit toujours exister + const remaining = await testDb.select().from(ajustementsTable); + assertEquals(remaining.length, 1); + assertEquals(remaining[0].idUE, ue2.id); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.DELETE!( + makeGetRequest("/ajustements/1/1"), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.DELETE!( + makeGetRequest("/ajustements/99999/99"), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/enseignements_test.ts b/tests/e2e/enseignements_test.ts new file mode 100644 index 0000000..32c9326 --- /dev/null +++ b/tests/e2e/enseignements_test.ts @@ -0,0 +1,240 @@ +// E2E tests for /enseignements endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedEnseignements, + seedModules, + seedPromotions, + seedUsers, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; +import { handler as enseignementHandler } from "$apps/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; + +// --- POST /enseignements --- + +Deno.test({ + name: + "e2e enseignements: POST /enseignements creates enseignement (201) as employee", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.idProf); + assertEquals(body.idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 400 on missing fields", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { idProf: "prof.dupont" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 409 on duplicate", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 409); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /enseignements/:idProf/:idModule/:idPromo --- + +Deno.test({ + name: + "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/prof.dupont/M1/P1"), + makeEmployeeContext({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.idProf, "prof.dupont"); + assertEquals(body.idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/p/M1/P1"), + makeContextWithAffiliation("student", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/ghost/GHOST/GHOST"), + makeEmployeeContext({ + idProf: "ghost", + idModule: "GHOST", + idPromo: "GHOST", + }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /enseignements/:idProf/:idModule/:idPromo --- + +Deno.test({ + name: + "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo returns 204 (employee)", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/prof.dupont/M1/P1"), + makeEmployeeContext({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/p/M1/P1"), + makeContextWithAffiliation("student", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/ghost/GHOST/GHOST"), + makeEmployeeContext({ + idProf: "ghost", + idModule: "GHOST", + idPromo: "GHOST", + }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts new file mode 100644 index 0000000..3077062 --- /dev/null +++ b/tests/e2e/modules_test.ts @@ -0,0 +1,210 @@ +// #113 - E2E tests for /modules endpoints + +import { assertEquals } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedModules, truncateAll } from "../helpers/db_integration.ts"; +import { handler as modulesHandler } from "$apps/admin/api/modules.ts"; +import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts"; + +// --- GET /modules --- + +Deno.test({ + name: "e2e modules: GET /modules returns all as employee", + async fn() { + await truncateAll(); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }, { + id: "INFO101", + nom: "Informatique", + }]); + const res = await modulesHandler.GET!( + makeGetRequest("/modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: GET /modules returns all for non-employee", + async fn() { + await truncateAll(); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); + const res = await modulesHandler.GET!( + makeGetRequest("/modules"), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /modules --- + +Deno.test({ + name: "e2e modules: POST /modules creates module (201)", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "PHYS101", nom: "Physique" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertEquals(body.id, "PHYS101"); + assertEquals(body.nom, "Physique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: POST /modules 409 on duplicate id", + async fn() { + await truncateAll(); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "MATH101", nom: "Doublon" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 409); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: POST /modules 400 on missing fields", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "X" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: POST /modules 403 for non-employee", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "X", nom: "Y" }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /modules/:id --- + +Deno.test({ + name: "e2e modules: GET /modules/:id returns module", + async fn() { + await truncateAll(); + await seedModules([{ id: "ELEC201", nom: "Électronique" }]); + const res = await moduleHandler.GET!( + makeGetRequest("/modules/ELEC201"), + makeEmployeeContext({ idModule: "ELEC201" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "Électronique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: GET /modules/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await moduleHandler.GET!( + makeGetRequest("/modules/GHOST"), + makeEmployeeContext({ idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /modules/:id --- + +Deno.test({ + name: "e2e modules: PUT /modules/:id updates nom", + async fn() { + await truncateAll(); + await seedModules([{ id: "CHIM101", nom: "Chimie" }]); + const res = await moduleHandler.PUT!( + makeJsonRequest("/modules/CHIM101", "PUT", { nom: "Chimie organique" }), + makeEmployeeContext({ idModule: "CHIM101" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "Chimie organique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: PUT /modules/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await moduleHandler.PUT!( + makeJsonRequest("/modules/GHOST", "PUT", { nom: "X" }), + makeEmployeeContext({ idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /modules/:id --- + +Deno.test({ + name: "e2e modules: DELETE /modules/:id returns 204", + async fn() { + await truncateAll(); + await seedModules([{ id: "BIO101", nom: "Biologie" }]); + const res = await moduleHandler.DELETE!( + makeGetRequest("/modules/BIO101"), + makeEmployeeContext({ idModule: "BIO101" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e modules: DELETE /modules/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await moduleHandler.DELETE!( + makeGetRequest("/modules/GHOST"), + makeEmployeeContext({ idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/notes_test.ts b/tests/e2e/notes_test.ts new file mode 100644 index 0000000..ee1f491 --- /dev/null +++ b/tests/e2e/notes_test.ts @@ -0,0 +1,283 @@ +// E2E tests for /notes endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedNotes, + seedPromotions, + seedStudents, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as notesHandler } from "$apps/notes/api/notes.ts"; +import { handler as noteHandler } from "$apps/notes/api/notes/[numEtud]/[idModule].ts"; + +// --- GET /notes --- + +Deno.test({ + name: "e2e notes: GET /notes returns all notes", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedNotes([ + { numEtud: s.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s.numEtud, idModule: "M2", note: 12.0 }, + ]); + const res = await notesHandler.GET!( + makeGetRequest("/notes"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?numEtud filters by student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + const [s2] = await seedStudents([{ + nom: "Martin", + prenom: "Alice", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([ + { numEtud: s1.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s2.numEtud, idModule: "M1", note: 12.0 }, + ]); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { numEtud: String(s1.numEtud) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?numEtud=NaN returns 400", + async fn() { + await truncateAll(); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { numEtud: "abc" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?idModule filters by module", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedNotes([ + { numEtud: s.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s.numEtud, idModule: "M2", note: 10.0 }, + ]); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { idModule: "M1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /notes --- + +Deno.test({ + name: "e2e notes: POST /notes creates note (201)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Leroy", + prenom: "Paul", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { + numEtud: s.numEtud, + idModule: "M1", + note: 14.0, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.numEtud); + assertEquals(body.note, 14.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: POST /notes 400 on missing fields", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { numEtud: 12345 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: GET /notes/:numEtud/:idModule returns note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Bernard", + prenom: "Lucie", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 18.0 }]); + const res = await noteHandler.GET!( + makeGetRequest(`/notes/${s.numEtud}/M1`), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.note, 18.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.GET!( + makeGetRequest("/notes/99999/GHOST"), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: PUT /notes/:numEtud/:idModule updates note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Thomas", + prenom: "Eva", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 10.0 }]); + const res = await noteHandler.PUT!( + makeJsonRequest(`/notes/${s.numEtud}/M1`, "PUT", { note: 16.0 }), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.note, 16.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: PUT /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.PUT!( + makeJsonRequest("/notes/99999/GHOST", "PUT", { note: 10.0 }), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: DELETE /notes/:numEtud/:idModule returns 204", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Petit", + prenom: "Hugo", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 9.0 }]); + const res = await noteHandler.DELETE!( + makeGetRequest(`/notes/${s.numEtud}/M1`), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: DELETE /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.DELETE!( + makeGetRequest("/notes/99999/GHOST"), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/permissions_test.ts b/tests/e2e/permissions_test.ts new file mode 100644 index 0000000..8e6c474 --- /dev/null +++ b/tests/e2e/permissions_test.ts @@ -0,0 +1,60 @@ +// #115 - E2E tests for GET /permissions + +import { assertEquals, assertExists } from "@std/assert"; +import { makeEmployeeContext, makeGetRequest } from "../helpers/handler.ts"; +import { seedPermissions, truncateAll } from "../helpers/db_integration.ts"; +import { handler as permissionsHandler } from "$apps/admin/api/permissions.ts"; + +const PERMISSIONS = [ + { id: "note_read", nom: "Consulter les notes des étudiants" }, + { id: "note_write", nom: "Saisir et modifier les notes" }, + { id: "student_read", nom: "Consulter la liste des étudiants" }, + { + id: "student_write", + nom: "Gérer les étudiants (ajout, modification, suppression)", + }, + { id: "module_read", nom: "Consulter les modules et enseignements" }, + { id: "module_write", nom: "Gérer les modules et enseignements" }, + { id: "user_read", nom: "Consulter les utilisateurs et leurs rôles" }, + { id: "user_write", nom: "Gérer les utilisateurs et leurs rôles" }, + { id: "role_write", nom: "Gérer les rôles et leurs permissions" }, +]; + +Deno.test({ + name: "e2e permissions: GET /permissions returns all 9 permissions", + async fn() { + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( + makeGetRequest("/permissions"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const text = await res.text(); + const data = JSON.parse(text); + assertEquals(data.length, 9); + assertExists(data.find((p: { id: string }) => p.id === "student_read")); + assertExists(data.find((p: { id: string }) => p.id === "role_write")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e permissions: GET /permissions - all entries have id and nom", + async fn() { + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( + makeGetRequest("/permissions"), + makeEmployeeContext(), + ); + const data: { id: string; nom: string }[] = await res.json(); + for (const p of data) { + assertEquals(typeof p.id, "string"); + assertEquals(typeof p.nom, "string"); + } + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/promotions_test.ts b/tests/e2e/promotions_test.ts new file mode 100644 index 0000000..b296229 --- /dev/null +++ b/tests/e2e/promotions_test.ts @@ -0,0 +1,212 @@ +// #110 - E2E tests for /promotions endpoints + +import { assertEquals } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedPromotions, truncateAll } from "../helpers/db_integration.ts"; +import { handler as promotionsHandler } from "$apps/students/api/promotions.ts"; +import { handler as promotionHandler } from "$apps/students/api/promotions/[idPromo].ts"; + +// --- GET /promotions --- + +Deno.test({ + name: "e2e promotions: GET /promotions returns all as employee", + async fn() { + await truncateAll(); + await seedPromotions([ + { id: "PEIP1-2024", annee: "2024" }, + { id: "PEIP2-2024", annee: "2024" }, + ]); + const res = await promotionsHandler.GET!( + makeGetRequest("/promotions"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions returns empty for non-employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024", annee: "2024" }]); + const res = await promotionsHandler.GET!( + makeGetRequest("/promotions"), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /promotions --- + +Deno.test({ + name: "e2e promotions: POST /promotions creates promotion (201)", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { + idPromo: "INFO3-2025", + annee: "2025", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertEquals(body.id, "INFO3-2025"); + assertEquals(body.annee, "2025"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: POST /promotions 403 for non-employee", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { idPromo: "X", annee: "2025" }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: POST /promotions 400 on missing fields", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { idPromo: "X" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: GET /promotions/:id returns promotion", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024", annee: "2024" }]); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/INFO3-2024"), + makeEmployeeContext({ idPromo: "INFO3-2024" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.id, "INFO3-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/GHOST"), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions/:id 403 for non-employee", + async fn() { + await truncateAll(); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/INFO3-2024"), + makeContextWithAffiliation("student", { idPromo: "INFO3-2024" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: PUT /promotions/:id updates annee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2023", annee: "2023" }]); + const res = await promotionHandler.PUT!( + makeJsonRequest("/promotions/INFO3-2023", "PUT", { annee: "2024" }), + makeEmployeeContext({ idPromo: "INFO3-2023" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.annee, "2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: PUT /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.PUT!( + makeJsonRequest("/promotions/GHOST", "PUT", { annee: "2025" }), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: DELETE /promotions/:id returns 204", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2022", annee: "2022" }]); + const res = await promotionHandler.DELETE!( + makeGetRequest("/promotions/INFO3-2022"), + makeEmployeeContext({ idPromo: "INFO3-2022" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: DELETE /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.DELETE!( + makeGetRequest("/promotions/GHOST"), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/robustness_test.ts b/tests/e2e/robustness_test.ts new file mode 100644 index 0000000..ced5ac4 --- /dev/null +++ b/tests/e2e/robustness_test.ts @@ -0,0 +1,592 @@ +// Robustness tests — input validation & side-effect isolation +// +// Chaque test documente le comportement réel du handler face à des entrées invalides. +// Les tests marqués [BUG] représentent le comportement ATTENDU — ils échouent +// intentionnellement pour exposer un bug dans le handler ciblé. + +import { assertEquals } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedPromotions, + seedStudents, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as modulesHandler } from "$apps/admin/api/modules.ts"; +import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts"; +import { handler as notesHandler } from "$apps/notes/api/notes.ts"; +import { handler as uesHandler } from "$apps/admin/api/ues.ts"; +import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts"; +import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; +import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; +import { handler as usersHandler } from "$apps/admin/api/users.ts"; + +// Helper : request POST avec un body JSON invalide +function makeMalformedRequest(path: string): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ ceci n'est pas du json }", + }); +} + +// Helper : request POST sans body du tout +function makeEmptyBodyRequest(path: string, method = "POST"): Request { + return new Request(`http://localhost${path}`, { method }); +} + +// ============================================================================= +// JSON MALFORMÉ +// ============================================================================= +// Handlers AVEC try/catch → retournent 500 +// Handlers SANS try/catch → throwent (assertRejects) + +Deno.test({ + name: "robustness: POST /notes malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeMalformedRequest("/notes"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ues malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeMalformedRequest("/ues"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ue-modules malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await ueModulesHandler.POST!( + makeMalformedRequest("/ue-modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "robustness: POST /ajustements malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeMalformedRequest("/ajustements"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /modules malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeMalformedRequest("/modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /enseignements malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeMalformedRequest("/enseignements"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /users malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeMalformedRequest("/users"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// BODY ABSENT +// ============================================================================= + +Deno.test({ + name: "robustness: POST /notes sans body → 500", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeEmptyBodyRequest("/notes"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /modules sans body → 500", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeEmptyBodyRequest("/modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAÎNES VIDES — comportement correct ✓ +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules id vide → 400", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "", nom: "Test" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /modules nom vide → 400", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "M1", nom: "" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ues nom vide → 400", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: "" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAÎNES AVEC ESPACES SEULS — [BUG] passent !field et s'insèrent en DB +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules id=espaces → 400", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: " ", nom: "Test" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ues nom=espaces → 400", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: " " }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /users id=espaces → 400", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// MAUVAIS TYPES +// ============================================================================= + +Deno.test({ + name: "robustness: POST /notes note=string → 400", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Test", + prenom: "User", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { + note: "pas-un-nombre", + numEtud: s.numEtud, + idModule: "M1", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: PUT /modules/:id nom=number → 400", + async fn() { + await truncateAll(); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await moduleHandler.PUT!( + makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), + makeEmployeeContext({ idModule: "M1" }), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// VALEUR ZÉRO — falsy bug sur numEtud/idUE +// ============================================================================= + +Deno.test({ + name: + "robustness [BUG]: POST /ajustements numEtud=0 → 400 pour mauvaise raison", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { + numEtud: 0, + idUE: ue.id, + valeur: 10.0, + }), + makeEmployeeContext(), + ); + // !0 === true → retourne 400 à cause du falsy check, pas d'une vraie validation + // Comportement attendu : 422 ou message d'erreur explicite sur numEtud invalide + // Comportement réel : 400 générique "champs requis" + assertEquals(res.status, 400); // passe, mais pour la mauvaise raison — le message est trompeur + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ajustements idUE=0 → 400 pour mauvaise raison", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Test", + prenom: "User", + idPromo: "P1", + }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { + numEtud: s.numEtud, + idUE: 0, + valeur: 10.0, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // !0 → 400, message trompeur + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// VALEUR ZÉRO CORRECTEMENT GÉRÉE — coeff=0 est valide +// ============================================================================= + +Deno.test({ + name: + "robustness: POST /ue-modules coeff=0 → 201 (zéro est une valeur valide)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 0, + }), + makeEmployeeContext(), + ); + // coeff === undefined → false pour 0 → passe ✓ + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// INJECTION SQL DANS LES PARAMÈTRES D'URL +// Drizzle utilise des requêtes paramétrées → les injections sont neutralisées +// ============================================================================= + +Deno.test({ + name: + "robustness: GET /modules avec SQL injection dans id → 404 (Drizzle paramètre)", + async fn() { + await truncateAll(); + const injectionId = "'; DROP TABLE modules; --"; + const res = await moduleHandler.GET!( + makeGetRequest(`/modules/${encodeURIComponent(injectionId)}`), + makeEmployeeContext({ idModule: injectionId }), + ); + // Drizzle génère WHERE id = $1 avec $1 = "'; DROP TABLE modules; --" + // Aucune injection possible → module non trouvé → 404 + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "robustness: POST /modules avec SQL injection dans id → s'insère littéralement (safe)", + async fn() { + await truncateAll(); + const injectionId = "'; DROP TABLE modules; --"; + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: injectionId, nom: "Test" }), + makeEmployeeContext(), + ); + // Drizzle paramètre la valeur → s'insère comme une chaîne ordinaire → 201 + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ABSENCE DE VALIDATION MÉTIER — valeurs hors limites acceptées +// ============================================================================= + +Deno.test({ + name: "robustness: POST /notes note > 20 → 400", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Test", + prenom: "User", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { + note: 999, + numEtud: s.numEtud, + idModule: "M1", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /notes note < 0 → 400", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Test", + prenom: "User", + idPromo: "P1", + }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { + note: -5, + numEtud: s.numEtud, + idModule: "M1", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ue-modules coeff négatif → 400", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: -3, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ISOLATION DES EFFETS DE BORD +// Vérification que truncateAll() isole correctement chaque test +// ============================================================================= + +Deno.test({ + name: "robustness: isolation — données du test précédent non visibles", + async fn() { + // Ce test crée un module + await truncateAll(); + await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { + id: "ISOLATION-TEST", + nom: "Test", + }), + makeEmployeeContext(), + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "robustness: isolation — truncateAll efface bien les données du test précédent", + async fn() { + await truncateAll(); + // Le module créé dans le test précédent ne doit plus exister + const res = await moduleHandler.GET!( + makeGetRequest("/modules/ISOLATION-TEST"), + makeEmployeeContext({ idModule: "ISOLATION-TEST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAMPS SUPPLÉMENTAIRES INCONNUS — doivent être ignorés silencieusement +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules avec champs inconnus → 201 (champs ignorés)", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { + id: "M-EXTRA", + nom: "Test", + champInconnu: "valeur", + _admin: true, + __proto__: { polluted: true }, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ACCÈS NON AUTHENTIFIÉ — vérification que l'état auth est bien contrôlé +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules sans affiliation employee → 403", + async fn() { + await truncateAll(); + for (const role of ["student", "alumni", "", "EMPLOYEE", "admin"]) { + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: `M-${role}`, nom: "Test" }), + makeContextWithAffiliation(role), + ); + assertEquals(res.status, 403, `role "${role}" devrait être 403`); + } + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/roles_test.ts b/tests/e2e/roles_test.ts new file mode 100644 index 0000000..8026434 --- /dev/null +++ b/tests/e2e/roles_test.ts @@ -0,0 +1,175 @@ +// #112 - E2E tests for /roles endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedRoles, testDb, truncateAll } from "../helpers/db_integration.ts"; +import { permissions } from "$root/databases/schema.ts"; +import { handler as rolesHandler } from "$apps/admin/api/roles.ts"; +import { handler as roleHandler } from "$apps/admin/api/roles/[idRole].ts"; + +// --- GET /roles --- + +Deno.test({ + name: "e2e roles: GET /roles returns all with permissions", + async fn() { + await truncateAll(); + await seedRoles([{ nom: "admin" }, { nom: "employee" }]); + const res = await rolesHandler.GET!( + makeGetRequest("/roles"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + assertExists(body[0].permissions); + assertEquals(Array.isArray(body[0].permissions), true); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /roles --- + +Deno.test({ + name: "e2e roles: POST /roles creates role (201)", + async fn() { + await truncateAll(); + const res = await rolesHandler.POST!( + makeJsonRequest("/roles", "POST", { nom: "viewer" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.id); + assertEquals(body.nom, "viewer"); + assertEquals(body.permissions, []); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e roles: POST /roles 400 on missing nom", + async fn() { + await truncateAll(); + const res = await rolesHandler.POST!( + makeJsonRequest("/roles", "POST", {}), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /roles/:id --- + +Deno.test({ + name: "e2e roles: GET /roles/:id returns role with permissions", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "admin" }]); + await testDb.insert(permissions).values([ + { id: "student_read", nom: "Consulter les élèves" }, + ]); + const res = await roleHandler.GET!( + makeGetRequest(`/roles/${role.id}`), + makeEmployeeContext({ idRole: String(role.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "admin"); + assertEquals(Array.isArray(body.permissions), true); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e roles: GET /roles/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await roleHandler.GET!( + makeGetRequest("/roles/9999"), + makeEmployeeContext({ idRole: "9999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /roles/:id --- + +Deno.test({ + name: "e2e roles: PUT /roles/:id updates nom and permissions", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "employee" }]); + await testDb.insert(permissions).values([ + { id: "note_read", nom: "Consulter les notes" }, + ]); + const res = await roleHandler.PUT!( + makeJsonRequest(`/roles/${role.id}`, "PUT", { + nom: "teacher", + permissions: ["note_read"], + }), + makeEmployeeContext({ idRole: String(role.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "teacher"); + assertEquals(body.permissions, ["note_read"]); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e roles: PUT /roles/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await roleHandler.PUT!( + makeJsonRequest("/roles/9999", "PUT", { nom: "ghost", permissions: [] }), + makeEmployeeContext({ idRole: "9999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /roles/:id --- + +Deno.test({ + name: "e2e roles: DELETE /roles/:id returns 204", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "moderator" }]); + const res = await roleHandler.DELETE!( + makeGetRequest(`/roles/${role.id}`), + makeEmployeeContext({ idRole: String(role.id) }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e roles: DELETE /roles/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await roleHandler.DELETE!( + makeGetRequest("/roles/9999"), + makeEmployeeContext({ idRole: "9999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/students_test.ts b/tests/e2e/students_test.ts new file mode 100644 index 0000000..e02103f --- /dev/null +++ b/tests/e2e/students_test.ts @@ -0,0 +1,288 @@ +// #109 - E2E tests for /students endpoints +// Appelle les handlers Fresh directement avec un vrai contexte + vraie DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedPromotions, + seedStudents, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as studentsHandler } from "$apps/students/api/students.ts"; +import { handler as studentHandler } from "$apps/students/api/students/[numEtud].ts"; + +// --- GET /students --- + +Deno.test({ + name: "e2e students: GET /students returns all students as employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024" }]); + await seedStudents([ + { nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" }, + { nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" }, + ]); + + const req = makeGetRequest("/students"); + const ctx = makeEmployeeContext(); + const res = await studentsHandler.GET!(req, ctx); + + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + assertExists(body.find((s: { nom: string }) => s.nom === "Dupont")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: GET /students returns empty array for non-employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024" }]); + await seedStudents([ + { nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" }, + ]); + + const req = makeGetRequest("/students"); + const ctx = makeContextWithAffiliation("student"); + const res = await studentsHandler.GET!(req, ctx); + + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: GET /students?idPromo filters by promotion", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024" }, { id: "PEIP2-2024" }]); + await seedStudents([ + { nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" }, + { nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" }, + { nom: "Durand", prenom: "Claire", idPromo: "PEIP2-2024" }, + ]); + + const req = makeGetRequest("/students", { idPromo: "PEIP1-2024" }); + const ctx = makeEmployeeContext(); + const res = await studentsHandler.GET!(req, ctx); + + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + assertEquals( + body.every((s: { idPromo: string }) => s.idPromo === "PEIP1-2024"), + true, + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /students --- + +Deno.test({ + name: "e2e students: POST /students creates a student (201)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + + const req = makeJsonRequest("/students", "POST", { + nom: "Leroy", + prenom: "Paul", + idPromo: "INFO3-2024", + }); + const ctx = makeEmployeeContext(); + const res = await studentsHandler.POST!(req, ctx); + + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.numEtud); + assertEquals(body.nom, "Leroy"); + assertEquals(body.idPromo, "INFO3-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: POST /students 403 for non-employee", + async fn() { + await truncateAll(); + + const req = makeJsonRequest("/students", "POST", { + nom: "Test", + prenom: "User", + idPromo: "PEIP1-2024", + }); + const ctx = makeContextWithAffiliation("student"); + const res = await studentsHandler.POST!(req, ctx); + + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: POST /students 400 when missing required fields", + async fn() { + await truncateAll(); + + const req = makeJsonRequest("/students", "POST", { nom: "Leroy" }); + const ctx = makeEmployeeContext(); + const res = await studentsHandler.POST!(req, ctx); + + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /students/:numEtud --- + +Deno.test({ + name: "e2e students: GET /students/:numEtud returns student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + const [s] = await seedStudents([ + { nom: "Bernard", prenom: "Lucie", idPromo: "INFO3-2024" }, + ]); + + const req = makeGetRequest(`/students/${s.numEtud}`); + const ctx = makeEmployeeContext({ numEtud: String(s.numEtud) }); + const res = await studentHandler.GET!(req, ctx); + + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.numEtud, s.numEtud); + assertEquals(body.nom, "Bernard"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: GET /students/:numEtud 404 when not found", + async fn() { + await truncateAll(); + + const req = makeGetRequest("/students/999999"); + const ctx = makeEmployeeContext({ numEtud: "999999" }); + const res = await studentHandler.GET!(req, ctx); + + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: GET /students/:numEtud 403 for non-employee", + async fn() { + await truncateAll(); + + const req = makeGetRequest("/students/12345"); + const ctx = makeContextWithAffiliation("student", { numEtud: "12345" }); + const res = await studentHandler.GET!(req, ctx); + + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /students/:numEtud --- + +Deno.test({ + name: "e2e students: PUT /students/:numEtud updates student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }, { id: "INFO4-2024" }]); + const [s] = await seedStudents([ + { nom: "Petit", prenom: "Hugo", idPromo: "INFO3-2024" }, + ]); + + const req = makeJsonRequest(`/students/${s.numEtud}`, "PUT", { + nom: "Grand", + prenom: "Hugo", + idPromo: "INFO4-2024", + }); + const ctx = makeEmployeeContext({ numEtud: String(s.numEtud) }); + const res = await studentHandler.PUT!(req, ctx); + + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "Grand"); + assertEquals(body.idPromo, "INFO4-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: PUT /students/:numEtud 404 when not found", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + + const req = makeJsonRequest("/students/999999", "PUT", { + nom: "Ghost", + prenom: "Ghost", + idPromo: "INFO3-2024", + }); + const ctx = makeEmployeeContext({ numEtud: "999999" }); + const res = await studentHandler.PUT!(req, ctx); + + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /students/:numEtud --- + +Deno.test({ + name: "e2e students: DELETE /students/:numEtud returns 204", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + const [s] = await seedStudents([ + { nom: "Thomas", prenom: "Eva", idPromo: "INFO3-2024" }, + ]); + + const req = makeGetRequest(`/students/${s.numEtud}`); + const ctx = makeEmployeeContext({ numEtud: String(s.numEtud) }); + const res = await studentHandler.DELETE!(req, ctx); + + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e students: DELETE /students/:numEtud 404 when not found", + async fn() { + await truncateAll(); + + const req = makeGetRequest("/students/999999"); + const ctx = makeEmployeeContext({ numEtud: "999999" }); + const res = await studentHandler.DELETE!(req, ctx); + + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts new file mode 100644 index 0000000..30dba17 --- /dev/null +++ b/tests/e2e/ue_modules_test.ts @@ -0,0 +1,312 @@ +// E2E tests for /ue-modules endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedPromotions, + seedUeModules, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts"; +import { handler as ueModuleHandler } from "$apps/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import { ueModules as ueModulesTable } from "$root/databases/schema.ts"; +import { testDb } from "../helpers/db_integration.ts"; + +// --- GET /ue-modules --- + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules returns all associations", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M2", idUE: ue.id, idPromo: "P1", coeff: 3.0 }, + ]); + const res = await ueModulesHandler.GET!( + makeGetRequest("/ue-modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules?idPromo filters by promo", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue.id, idPromo: "P2", coeff: 3.0 }, + ]); + const res = await ueModulesHandler.GET!( + makeGetRequest("/ue-modules", { idPromo: "P1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ue-modules --- + +Deno.test({ + name: "e2e ue_modules: POST /ue-modules creates association (201)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 4.0, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.idModule); + assertEquals(body.coeff, 4.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: POST /ue-modules 400 on missing fields", + async fn() { + await truncateAll(); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { idModule: "M1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: + "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo returns correct association (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Plusieurs lignes qui partagent idModule="M1" — le handler doit discriminer par idUE ET idPromo + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 3.5 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P1", coeff: 1.0 }, + { idModule: "M1", idUE: ue1.id, idPromo: "P2", coeff: 2.0 }, + { idModule: "M2", idUE: ue1.id, idPromo: "P1", coeff: 4.0 }, + ]); + const res = await ueModuleHandler.GET!( + makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + // Doit retourner exactement M1/ue1/P1 avec coeff 3.5, pas une autre ligne + assertEquals(body.coeff, 3.5); + assertEquals(body.idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.GET!( + makeGetRequest("/ue-modules/M1/1/P1"), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.GET!( + makeGetRequest("/ue-modules/GHOST/1/GHOST"), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: + "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo updates only the targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Deux lignes avec même idModule — le PUT ne doit modifier que celle ciblée + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P2", coeff: 9.0 }, + ]); + const res = await ueModuleHandler.PUT!( + makeJsonRequest(`/ue-modules/M1/${ue1.id}/P1`, "PUT", { coeff: 5.0 }), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.coeff, 5.0); + assertEquals(body.idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.PUT!( + makeJsonRequest("/ue-modules/M1/1/P1", "PUT", { coeff: 5.0 }), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.PUT!( + makeJsonRequest("/ue-modules/GHOST/1/GHOST", "PUT", { coeff: 5.0 }), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: + "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo deletes only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Deux lignes avec même idModule — seule celle ciblée doit être supprimée + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P2", coeff: 4.0 }, + ]); + const res = await ueModuleHandler.DELETE!( + makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), + ); + assertEquals(res.status, 204); + // L'autre ligne doit toujours exister + const remaining = await testDb.select().from(ueModulesTable); + assertEquals(remaining.length, 1); + assertEquals(remaining[0].idUE, ue2.id); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.DELETE!( + makeGetRequest("/ue-modules/M1/1/P1"), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.DELETE!( + makeGetRequest("/ue-modules/GHOST/1/GHOST"), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/ues_test.ts b/tests/e2e/ues_test.ts new file mode 100644 index 0000000..d5d726d --- /dev/null +++ b/tests/e2e/ues_test.ts @@ -0,0 +1,178 @@ +// E2E tests for /ues endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedUes, truncateAll } from "../helpers/db_integration.ts"; +import { handler as uesHandler } from "$apps/admin/api/ues.ts"; +import { handler as ueHandler } from "$apps/admin/api/ues/[idUE].ts"; + +// --- GET /ues --- + +Deno.test({ + name: "e2e ues: GET /ues returns all UEs", + async fn() { + await truncateAll(); + await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]); + const res = await uesHandler.GET!( + makeGetRequest("/ues"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: GET /ues returns empty when no UEs", + async fn() { + await truncateAll(); + const res = await uesHandler.GET!( + makeGetRequest("/ues"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ues --- + +Deno.test({ + name: "e2e ues: POST /ues creates UE (201)", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: "UE Physique" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.id); + assertEquals(body.nom, "UE Physique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: POST /ues 400 on missing nom", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", {}), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ues/:id --- + +Deno.test({ + name: "e2e ues: GET /ues/:id returns UE", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + const res = await ueHandler.GET!( + makeGetRequest(`/ues/${ue.id}`), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "UE Chimie"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: GET /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.GET!( + makeGetRequest("/ues/99999"), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ues/:id --- + +Deno.test({ + name: "e2e ues: PUT /ues/:id updates nom", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Biologie" }]); + const res = await ueHandler.PUT!( + makeJsonRequest(`/ues/${ue.id}`, "PUT", { + nom: "UE Biologie moléculaire", + }), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "UE Biologie moléculaire"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: PUT /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.PUT!( + makeJsonRequest("/ues/99999", "PUT", { nom: "X" }), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ues/:id --- + +Deno.test({ + name: "e2e ues: DELETE /ues/:id returns 204", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE à supprimer" }]); + const res = await ueHandler.DELETE!( + makeGetRequest(`/ues/${ue.id}`), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: DELETE /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.DELETE!( + makeGetRequest("/ues/99999"), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/users_test.ts b/tests/e2e/users_test.ts new file mode 100644 index 0000000..830aefa --- /dev/null +++ b/tests/e2e/users_test.ts @@ -0,0 +1,239 @@ +// E2E tests for /users endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedRoles, + seedUsers, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as usersHandler } from "$apps/admin/api/users.ts"; +import { handler as userHandler } from "$apps/admin/api/users/[id].ts"; + +// --- GET /users --- + +Deno.test({ + name: "e2e users: GET /users returns all users", + async fn() { + await truncateAll(); + await seedUsers([ + { id: "dupont.jean", nom: "Dupont", prenom: "Jean" }, + { id: "martin.alice", nom: "Martin", prenom: "Alice" }, + ]); + const res = await usersHandler.GET!( + makeGetRequest("/users"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + assertExists(body.find((u: { id: string }) => u.id === "dupont.jean")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users returns empty when no users", + async fn() { + await truncateAll(); + const res = await usersHandler.GET!( + makeGetRequest("/users"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users?idRole filters by role", + async fn() { + await truncateAll(); + const [role1] = await seedRoles([{ nom: "admin" }]); + const [role2] = await seedRoles([{ nom: "employee" }]); + await seedUsers([ + { id: "admin.user", nom: "Admin", prenom: "User", idRole: role1.id }, + { id: "emp.user", nom: "Emp", prenom: "User", idRole: role2.id }, + ]); + const res = await usersHandler.GET!( + makeGetRequest("/users", { idRole: String(role1.id) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].id, "admin.user"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /users --- + +Deno.test({ + name: "e2e users: POST /users creates user (201)", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { + id: "new.user", + nom: "New", + prenom: "User", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertEquals(body.id, "new.user"); + assertEquals(body.nom, "New"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: POST /users 400 on missing fields", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: "x" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: POST /users 409 on duplicate id", + async fn() { + await truncateAll(); + await seedUsers([{ id: "dupont.jean", nom: "Dupont", prenom: "Jean" }]); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { + id: "dupont.jean", + nom: "Doublon", + prenom: "X", + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 409); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /users/:id --- + +Deno.test({ + name: "e2e users: GET /users/:id returns user", + async fn() { + await truncateAll(); + await seedUsers([{ id: "bernard.lucie", nom: "Bernard", prenom: "Lucie" }]); + const res = await userHandler.GET!( + makeGetRequest("/users/bernard.lucie"), + makeEmployeeContext({ id: "bernard.lucie" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.id, "bernard.lucie"); + assertEquals(body.nom, "Bernard"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.GET!( + makeGetRequest("/users/ghost.user"), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /users/:id --- + +Deno.test({ + name: "e2e users: PUT /users/:id updates user", + async fn() { + await truncateAll(); + await seedUsers([{ id: "thomas.eva", nom: "Thomas", prenom: "Eva" }]); + const res = await userHandler.PUT!( + makeJsonRequest("/users/thomas.eva", "PUT", { + nom: "Thomas-Modifié", + prenom: "Eva", + idRole: null, + }), + makeEmployeeContext({ id: "thomas.eva" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "Thomas-Modifié"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: PUT /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.PUT!( + makeJsonRequest("/users/ghost.user", "PUT", { + nom: "X", + prenom: "Y", + idRole: null, + }), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /users/:id --- + +Deno.test({ + name: "e2e users: DELETE /users/:id returns 204", + async fn() { + await truncateAll(); + await seedUsers([{ id: "petit.hugo", nom: "Petit", prenom: "Hugo" }]); + const res = await userHandler.DELETE!( + makeGetRequest("/users/petit.hugo"), + makeEmployeeContext({ id: "petit.hugo" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: DELETE /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.DELETE!( + makeGetRequest("/users/ghost.user"), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts new file mode 100644 index 0000000..0f8af47 --- /dev/null +++ b/tests/helpers/api_mock.ts @@ -0,0 +1,123 @@ +// Mock de fetch() pour les tests — supporte méthodes HTTP et status codes + +type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +export interface MockRoute { + method?: HttpMethod; + status?: number; + body?: unknown; + headers?: Record; +} + +// deno-lint-ignore no-explicit-any +let _originalFetch: ((input: any, init?: any) => Promise) | null = + null; +let _calls: { url: string; method: string; body?: unknown }[] = []; + +/** + * Remplace globalThis.fetch par un mock configurable. + * + * Usage simple (GET 200 par défaut) : + * mockFetch({ "/students": studentsData }) + * + * Usage avancé (méthode + status) : + * mockFetch({ "/students": { method: "POST", status: 201, body: newStudent } }) + */ +export function mockFetch( + routes: Record, +): void { + _originalFetch = globalThis.fetch; + _calls = []; + + globalThis.fetch = ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = (init?.method ?? "GET").toUpperCase(); + + // Parse le body si présent + let reqBody: unknown = undefined; + if (init?.body) { + try { + reqBody = JSON.parse(init.body as string); + } catch { + reqBody = init.body; + } + } + + _calls.push({ url, method, body: reqBody }); + + for (const [pattern, config] of Object.entries(routes)) { + if (!url.includes(pattern)) continue; + + // Config simple : la valeur est directement le body de réponse (GET 200) + if (!isRouteConfig(config)) { + return new Response(JSON.stringify(config), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Config avancée : vérifier la méthode si spécifiée + if (config.method && config.method !== method) continue; + + const status = config.status ?? 200; + + // 204 : pas de body + if (status === 204) { + return new Response(null, { status: 204 }); + } + + return new Response( + config.body !== undefined ? JSON.stringify(config.body) : null, + { + status, + headers: { + "Content-Type": "application/json", + ...config.headers, + }, + }, + ); + } + + return new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +/** + * Restaure le fetch original. + */ +export function restoreFetch(): void { + if (_originalFetch) { + globalThis.fetch = _originalFetch; + _originalFetch = null; + } + _calls = []; +} + +/** + * Retourne la liste des appels fetch interceptés. + */ +export function getFetchCalls(): { + url: string; + method: string; + body?: unknown; +}[] { + return [..._calls]; +} + +function isRouteConfig(value: unknown): value is MockRoute { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const v = value as Record; + return "status" in v || "method" in v || "body" in v; +} diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts new file mode 100644 index 0000000..be102db --- /dev/null +++ b/tests/helpers/db_integration.ts @@ -0,0 +1,119 @@ +// Helper pour les tests d'intégration avec PostgreSQL +// Nécessite les variables d'environnement POSTGRES_* (ou TEST_DATABASE_URL) + +import { drizzle } from "npm:drizzle-orm@0.45.2/node-postgres"; +import pg from "npm:pg@8.20.0"; +import * as schema from "$root/databases/schema.ts"; + +const { Pool } = pg; + +function createTestPool(): pg.Pool { + const url = Deno.env.get("TEST_DATABASE_URL"); + if (url) { + return new Pool({ connectionString: url }); + } + return new Pool({ + host: Deno.env.get("POSTGRES_HOST") ?? "localhost", + port: Number(Deno.env.get("POSTGRES_PORT") ?? 5432), + user: Deno.env.get("POSTGRES_USER") ?? "test", + password: Deno.env.get("POSTGRES_PASS") ?? "test", + database: Deno.env.get("POSTGRES_DB") ?? "polympr_test", + ssl: false, + }); +} + +export const testPool = createTestPool(); +export const testDb = drizzle(testPool, { schema }); + +const ALL_TABLES = + '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; + +/** + * Vide toutes les tables dans le bon ordre. + * À appeler dans beforeEach de chaque test d'intégration. + */ +export async function truncateAll(): Promise { + const client = await testPool.connect(); + try { + await client.query( + `TRUNCATE TABLE ${ALL_TABLES} RESTART IDENTITY CASCADE`, + ); + } finally { + client.release(); + } +} + +/** + * Ferme le pool à la fin de la suite de tests. + */ +export async function closeTestPool(): Promise { + await testPool.end(); +} + +// --- Helpers d'insertion de fixtures --- + +export async function seedRoles( + rows: { nom: string }[], +): Promise { + return await testDb.insert(schema.roles).values(rows).returning(); +} + +export async function seedPromotions( + rows: { id: string; annee?: string }[], +): Promise { + return await testDb.insert(schema.promotions).values(rows).returning(); +} + +export async function seedStudents( + rows: { nom: string; prenom: string; idPromo?: string }[], +): Promise { + return await testDb.insert(schema.students).values(rows).returning(); +} + +export async function seedModules( + rows: { id: string; nom: string }[], +): Promise { + return await testDb.insert(schema.modules).values(rows).returning(); +} + +export async function seedUes( + rows: { nom: string }[], +): Promise { + return await testDb.insert(schema.ues).values(rows).returning(); +} + +export async function seedUsers( + rows: { id: string; nom: string; prenom: string; idRole?: number }[], +): Promise { + return await testDb.insert(schema.users).values(rows).returning(); +} + +export async function seedNotes( + rows: { numEtud: number; idModule: string; note: number }[], +): Promise { + return await testDb.insert(schema.notes).values(rows).returning(); +} + +export async function seedUeModules( + rows: { idModule: string; idUE: number; idPromo: string; coeff: number }[], +): Promise { + return await testDb.insert(schema.ueModules).values(rows).returning(); +} + +export async function seedEnseignements( + rows: { idProf: string; idModule: string; idPromo: string }[], +): Promise { + return await testDb.insert(schema.enseignements).values(rows).returning(); +} + +export async function seedAjustements( + rows: { numEtud: number; idUE: number; valeur: number }[], +): Promise { + return await testDb.insert(schema.ajustements).values(rows).returning(); +} + +export async function seedPermissions( + rows: { id: string; nom: string }[], +): Promise { + return await testDb.insert(schema.permissions).values(rows).returning(); +} diff --git a/tests/helpers/db_mock.ts b/tests/helpers/db_mock.ts new file mode 100644 index 0000000..4d18bb9 --- /dev/null +++ b/tests/helpers/db_mock.ts @@ -0,0 +1,122 @@ +// Mock de la couche Drizzle pour les tests unitaires/intégration +// Permet de tester les handlers sans connexion PostgreSQL + +export interface MockQueryResult { + rows: T[]; +} + +export interface MockDbConfig { + // Table name → array of rows + // deno-lint-ignore no-explicit-any + tables: Record[]>; +} + +/** + * Crée un mock de la DB Drizzle. + * Simule select/insert/update/delete avec un store en mémoire. + * + * Usage : + * ```ts + * const db = createMockDb({ + * tables: { + * students: [{ numEtud: 21212006, nom: "Dupont", ... }], + * notes: [], + * } + * }); + * + * // Lire toutes les lignes d'une table + * const rows = db.getTable("students"); + * + * // Insérer + * db.insert("students", { numEtud: 21212009, nom: "Test", ... }); + * + * // Trouver par clé + * const student = db.findOne("students", (r) => r.numEtud === 21212006); + * + * // Supprimer + * db.deleteWhere("students", (r) => r.numEtud === 21212006); + * ``` + */ +export function createMockDb(config: MockDbConfig) { + // Deep clone pour éviter les mutations entre tests + // deno-lint-ignore no-explicit-any + const tables: Record[]> = {}; + for (const [name, rows] of Object.entries(config.tables)) { + tables[name] = rows.map((r) => ({ ...r })); + } + + return { + /** Retourne toutes les lignes d'une table */ + getTable>(name: string): T[] { + return (tables[name] ?? []) as T[]; + }, + + /** Retourne les lignes qui matchent le filtre */ + findMany>( + name: string, + predicate: (row: T) => boolean, + ): T[] { + return (this.getTable(name)).filter(predicate); + }, + + /** Retourne la première ligne qui matche, ou undefined */ + findOne>( + name: string, + predicate: (row: T) => boolean, + ): T | undefined { + return (this.getTable(name)).find(predicate); + }, + + /** Insère une ligne dans la table */ + insert>(name: string, row: T): T { + if (!tables[name]) tables[name] = []; + const copy = { ...row } as T; + // deno-lint-ignore no-explicit-any + tables[name].push(copy as any); + return copy; + }, + + /** Met à jour les lignes qui matchent le prédicat */ + updateWhere>( + name: string, + predicate: (row: T) => boolean, + updates: Partial, + ): number { + const rows = this.getTable(name); + let count = 0; + for (const row of rows) { + if (predicate(row)) { + Object.assign(row as Record, updates); + count++; + } + } + return count; + }, + + /** Supprime les lignes qui matchent le prédicat */ + deleteWhere>( + name: string, + predicate: (row: T) => boolean, + ): number { + const before = (tables[name] ?? []).length; + tables[name] = (tables[name] ?? []).filter( + (r) => !predicate(r as unknown as T), + ); + return before - tables[name].length; + }, + + /** Vide une table */ + clear(name: string): void { + tables[name] = []; + }, + + /** Vide toutes les tables */ + reset(): void { + for (const name of Object.keys(tables)) { + tables[name] = []; + } + }, + }; +} + +export type MockDb = ReturnType; diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..67ece22 --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,137 @@ +// Types et données de test alignés sur l'API REST PolyMPR + +// --- Types --- + +export interface Student { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +} + +export interface Promotion { + idPromo: string; + annee: string; +} + +export interface Prof { + id: number; + nom: string; + prenom: string; +} + +export interface Module { + id: string; + nom: string; +} + +export interface Note { + note: number; + numEtud: number; + idModule: string; +} + +export interface UE { + id: number; + nom: string; +} + +export interface UeModule { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +} + +export interface Enseignement { + idProf: number; + idModule: string; + idPromo: string; +} + +export interface Ajustement { + numEtud: number; + idUE: number; + valeur: number; +} + +export interface ImportResult { + imported: number; + errors: { line: number; message: string }[]; +} + +export interface ApiError { + error: string; +} + +// --- Fixtures --- + +export const students: Student[] = [ + { numEtud: 21212006, nom: "Dupont", prenom: "Jean", idPromo: "4AFISE25/26" }, + { + numEtud: 21212007, + nom: "Martin", + prenom: "Alice", + idPromo: "4AFISE25/26", + }, + { + numEtud: 21212008, + nom: "Durand", + prenom: "Claire", + idPromo: "3AFISE25/26", + }, +]; + +export const promotions: Promotion[] = [ + { idPromo: "4AFISE25/26", annee: "2025" }, + { idPromo: "3AFISE25/26", annee: "2025" }, + { idPromo: "JIA4A2526", annee: "2025" }, +]; + +export const profs: Prof[] = [ + { id: 1, nom: "Leclerc", prenom: "Jean" }, + { id: 2, nom: "Moreau", prenom: "Sophie" }, +]; + +export const modules: Module[] = [ + { id: "JIN702C", nom: "Optimisation" }, + { id: "JIN703C", nom: "Informatique" }, + { id: "JIN704C", nom: "Physique" }, +]; + +export const notes: Note[] = [ + { note: 15.5, numEtud: 21212006, idModule: "JIN702C" }, + { note: 12.0, numEtud: 21212006, idModule: "JIN703C" }, + { note: 18.0, numEtud: 21212007, idModule: "JIN702C" }, + { note: 9.0, numEtud: 21212008, idModule: "JIN704C" }, +]; + +export const ues: UE[] = [ + { id: 1, nom: "UE Informatique" }, + { id: 2, nom: "UE Mathématiques" }, +]; + +export const ueModules: UeModule[] = [ + { idModule: "JIN702C", idUE: 1, idPromo: "4AFISE25/26", coeff: 3.0 }, + { idModule: "JIN703C", idUE: 2, idPromo: "4AFISE25/26", coeff: 4.0 }, + { idModule: "JIN704C", idUE: 1, idPromo: "3AFISE25/26", coeff: 2.0 }, +]; + +export const enseignements: Enseignement[] = [ + { idProf: 1, idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: 2, idModule: "JIN703C", idPromo: "4AFISE25/26" }, + { idProf: 1, idModule: "JIN704C", idPromo: "3AFISE25/26" }, +]; + +export const ajustements: Ajustement[] = [ + { numEtud: 21212006, idUE: 1, valeur: 13.25 }, + { numEtud: 21212008, idUE: 1, valeur: 11.0 }, +]; + +// --- Réponses d'erreur standard --- + +export const ERROR_NOT_FOUND: ApiError = { error: "Ressource introuvable" }; +export const ERROR_CONFLICT: ApiError = { error: "Ressource déjà existante" }; +export const ERROR_BAD_REQUEST: ApiError = { error: "Requête invalide" }; +export const ERROR_UNAUTHORIZED: ApiError = { error: "Non authentifié" }; +export const ERROR_FORBIDDEN: ApiError = { error: "Accès interdit" }; diff --git a/tests/helpers/handler.ts b/tests/helpers/handler.ts new file mode 100644 index 0000000..17aae24 --- /dev/null +++ b/tests/helpers/handler.ts @@ -0,0 +1,88 @@ +// Helper pour les tests E2E — appel direct des handlers Fresh +// sans lancer de serveur HTTP + +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { CasContent } from "$root/defaults/interfaces.ts"; + +const BASE_EMPLOYEE_SESSION: CasContent = { + amuCampus: "", + amuComposante: "", + amuDateValidation: "", + coGroup: "", + eduPersonPrimaryAffiliation: "employee", + eduPersonPrincipalName: "test.user@polytech.fr", + mail: "test.user@polytech.fr", + displayName: "Test User", + givenName: "Test", + memberOf: [], + sn: "User", + supannCivilite: "M.", + supannEntiteAffectation: "", + supannEtuAnneeInscription: "", + supannEtuEtape: "", + uid: "test.user", +}; + +/** + * Crée un FreshContext mock authentifié en tant qu'employee. + */ +export function makeEmployeeContext( + params: Record = {}, +): FreshContext { + return { + params, + state: { + isAuthenticated: true, + session: { ...BASE_EMPLOYEE_SESSION }, + availablePages: {}, + }, + render: () => Promise.resolve(new Response()), + renderNotFound: () => Promise.resolve(new Response(null, { status: 404 })), + next: () => Promise.resolve(new Response()), + } as unknown as FreshContext; +} + +/** + * Crée un FreshContext mock avec un affiliation personnalisée. + */ +export function makeContextWithAffiliation( + affiliation: string, + params: Record = {}, +): FreshContext { + const ctx = makeEmployeeContext(params); + (ctx.state as AuthenticatedState).session.eduPersonPrimaryAffiliation = + affiliation; + return ctx; +} + +/** + * Crée une Request GET simple. + */ +export function makeGetRequest( + path: string, + searchParams?: Record, +): Request { + const url = new URL(`http://localhost${path}`); + if (searchParams) { + for (const [k, v] of Object.entries(searchParams)) { + url.searchParams.set(k, v); + } + } + return new Request(url.toString()); +} + +/** + * Crée une Request POST/PUT avec un corps JSON. + */ +export function makeJsonRequest( + path: string, + method: string, + body: unknown, +): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/tests/helpers/render.ts b/tests/helpers/render.ts new file mode 100644 index 0000000..f87b1d9 --- /dev/null +++ b/tests/helpers/render.ts @@ -0,0 +1,55 @@ +// Setup happy-dom + wrapper render pour les tests de composants Preact + +import { Window } from "happy-dom"; + +let _window: Window | null = null; + +/** + * Initialise un environnement DOM virtuel via happy-dom. + * À appeler avant de rendre des composants Preact dans les tests. + */ +export function setupDOM(): void { + _window = new Window({ url: "http://localhost" }); + + // Expose les globals DOM nécessaires à Preact + const globals = _window as unknown as Record; + const target = globalThis as unknown as Record; + + for ( + const key of [ + "document", + "navigator", + "location", + "HTMLElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "HTMLSelectElement", + "Event", + "CustomEvent", + "KeyboardEvent", + "MouseEvent", + "InputEvent", + "MutationObserver", + "requestAnimationFrame", + "cancelAnimationFrame", + ] + ) { + target[key] = globals[key]; + } + + target["window"] = _window; +} + +/** + * Nettoie l'environnement DOM. + * À appeler dans un afterEach ou à la fin d'un test. + */ +export function cleanupDOM(): void { + if (_window) { + const doc = _window.document; + doc.body.innerHTML = ""; + doc.head.innerHTML = ""; + _window.close(); + _window = null; + } +} diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/ajustements_test.ts b/tests/integration/ajustements_test.ts new file mode 100644 index 0000000..49e6fcd --- /dev/null +++ b/tests/integration/ajustements_test.ts @@ -0,0 +1,160 @@ +// Integration tests for /ajustements — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedAjustements, + seedPromotions, + seedStudents, + seedUes, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { ajustements } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ajustements: list all ajustements", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }]); + const rows = await testDb.select().from(ajustements); + assertEquals(rows.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Martin", + prenom: "Alice", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Maths" }]); + + const [created] = await testDb + .insert(ajustements) + .values({ numEtud: s.numEtud, idUE: ue.id, valeur: 15.5 }) + .returning(); + assertExists(created); + assertEquals(created.valeur, 15.5); + + const row = await testDb + .select() + .from(ajustements) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.valeur, 15.5); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "integration ajustements: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(ajustements) + .where(and(eq(ajustements.numEtud, 99999), eq(ajustements.idUE, 99))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Durand", + prenom: "Claire", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 12.0 }]); + await assertRejects(() => + testDb.insert(ajustements).values({ + numEtud: s.numEtud, + idUE: ue.id, + valeur: 13.0, + }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: update valeur", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Bernard", + prenom: "Lucie", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Physique" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 10.0 }]); + + const [updated] = await testDb + .update(ajustements) + .set({ valeur: 18.0 }) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) + .returning(); + assertEquals(updated.valeur, 18.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: delete removes the ajustement", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ + nom: "Thomas", + prenom: "Eva", + idPromo: "P1", + }]); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 11.0 }]); + + await testDb.delete(ajustements).where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ); + const row = await testDb + .select() + .from(ajustements) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/enseignements_test.ts b/tests/integration/enseignements_test.ts new file mode 100644 index 0000000..40086a9 --- /dev/null +++ b/tests/integration/enseignements_test.ts @@ -0,0 +1,148 @@ +// Integration tests for /enseignements — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedEnseignements, + seedModules, + seedPromotions, + seedUsers, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration enseignements: list all enseignements", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([ + { idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }, + { idProf: "prof.dupont", idModule: "M2", idPromo: "P1" }, + ]); + const rows = await testDb.select().from(enseignements); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.moreau", nom: "Moreau", prenom: "Sophie" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + + const [created] = await testDb + .insert(enseignements) + .values({ idProf: "prof.moreau", idModule: "M1", idPromo: "P1" }) + .returning(); + assertExists(created); + assertEquals(created.idProf, "prof.moreau"); + + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.moreau"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "integration enseignements: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "ghost"), + eq(enseignements.idModule, "GHOST"), + eq(enseignements.idPromo, "GHOST"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); + await assertRejects(() => + testDb.insert(enseignements).values({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: delete removes the enseignement", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); + + await testDb + .delete(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.dupont"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ); + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.dupont"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/modules_test.ts b/tests/integration/modules_test.ts new file mode 100644 index 0000000..df32fba --- /dev/null +++ b/tests/integration/modules_test.ts @@ -0,0 +1,104 @@ +// #113 - Integration tests for /modules endpoints + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { seedModules, testDb, truncateAll } from "../helpers/db_integration.ts"; +import { modules } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration modules: list all modules", + async fn() { + await truncateAll(); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }, { + id: "INFO101", + nom: "Informatique", + }]); + const rows = await testDb.select().from(modules); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration modules: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb.insert(modules).values({ + id: "PHYS101", + nom: "Physique", + }).returning(); + assertExists(created); + assertEquals(created.id, "PHYS101"); + + const row = await testDb + .select() + .from(modules) + .where(eq(modules.id, "PHYS101")) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration modules: get by id returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(modules) + .where(eq(modules.id, "NONEXISTENT")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration modules: duplicate id insert fails", + async fn() { + await truncateAll(); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); + await assertRejects(() => + testDb.insert(modules).values({ id: "MATH101", nom: "Doublon" }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration modules: update nom", + async fn() { + await truncateAll(); + await seedModules([{ id: "ELEC201", nom: "Électronique" }]); + const [updated] = await testDb + .update(modules) + .set({ nom: "Électronique numérique" }) + .where(eq(modules.id, "ELEC201")) + .returning(); + assertEquals(updated.nom, "Électronique numérique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration modules: delete removes the module", + async fn() { + await truncateAll(); + await seedModules([{ id: "BIO101", nom: "Biologie" }]); + await testDb.delete(modules).where(eq(modules.id, "BIO101")); + const row = await testDb + .select() + .from(modules) + .where(eq(modules.id, "BIO101")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/notes_test.ts b/tests/integration/notes_test.ts new file mode 100644 index 0000000..b9018b9 --- /dev/null +++ b/tests/integration/notes_test.ts @@ -0,0 +1,154 @@ +// Integration tests for /notes — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedModules, + seedNotes, + seedPromotions, + seedStudents, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { notes } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration notes: list all notes", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ + nom: "Dupont", + prenom: "Jean", + idPromo: "PROMO-2024", + }]); + await seedModules([{ id: "MOD101", nom: "Module A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD101", note: 15.5 }]); + const rows = await testDb.select().from(notes); + assertEquals(rows.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ + nom: "Martin", + prenom: "Alice", + idPromo: "PROMO-2024", + }]); + await seedModules([{ id: "MOD102", nom: "Module B" }]); + + const [created] = await testDb.insert(notes).values({ + numEtud: s.numEtud, + idModule: "MOD102", + note: 12.0, + }).returning(); + assertExists(created); + assertEquals(created.note, 12.0); + + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD102"))) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.note, 12.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, 99999), eq(notes.idModule, "GHOST"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ + nom: "Durand", + prenom: "Claire", + idPromo: "PROMO-2024", + }]); + await seedModules([{ id: "MOD103", nom: "Module C" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD103", note: 10.0 }]); + await assertRejects(() => + testDb.insert(notes).values({ + numEtud: s.numEtud, + idModule: "MOD103", + note: 11.0, + }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: update note value", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ + nom: "Bernard", + prenom: "Lucie", + idPromo: "PROMO-2024", + }]); + await seedModules([{ id: "MOD104", nom: "Module D" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD104", note: 8.0 }]); + + const [updated] = await testDb + .update(notes) + .set({ note: 16.0 }) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD104"))) + .returning(); + assertEquals(updated.note, 16.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: delete removes the note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ + nom: "Thomas", + prenom: "Eva", + idPromo: "PROMO-2024", + }]); + await seedModules([{ id: "MOD105", nom: "Module E" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD105", note: 14.0 }]); + + await testDb.delete(notes).where( + and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105")), + ); + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/promotions_test.ts b/tests/integration/promotions_test.ts new file mode 100644 index 0000000..07b24fd --- /dev/null +++ b/tests/integration/promotions_test.ts @@ -0,0 +1,112 @@ +// #110 - Integration tests for /promotions endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { + seedPromotions, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { promotions } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration promotions: list all", + async fn() { + await truncateAll(); + await seedPromotions([ + { id: "PEIP1-2024", annee: "2024" }, + { id: "PEIP2-2024", annee: "2024" }, + ]); + const rows = await testDb.select().from(promotions); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb + .insert(promotions) + .values({ id: "INFO3-2025", annee: "2025" }) + .returning(); + assertExists(created); + assertEquals(created.id, "INFO3-2025"); + assertEquals(created.annee, "2025"); + + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "INFO3-2025")) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: get by id returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "NONEXISTENT")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: update annee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2023", annee: "2023" }]); + const [updated] = await testDb + .update(promotions) + .set({ annee: "2024" }) + .where(eq(promotions.id, "INFO3-2023")) + .returning(); + assertExists(updated); + assertEquals(updated.annee, "2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: delete removes the row", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2022", annee: "2022" }]); + await testDb.delete(promotions).where(eq(promotions.id, "INFO3-2022")); + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "INFO3-2022")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: update non-existent returns empty", + async fn() { + await truncateAll(); + const result = await testDb + .update(promotions) + .set({ annee: "2099" }) + .where(eq(promotions.id, "GHOST")) + .returning(); + assertEquals(result.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/roles_test.ts b/tests/integration/roles_test.ts new file mode 100644 index 0000000..9fb7a6c --- /dev/null +++ b/tests/integration/roles_test.ts @@ -0,0 +1,123 @@ +// #112 - Integration tests for /roles endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { seedRoles, testDb, truncateAll } from "../helpers/db_integration.ts"; +import { permissions, rolePermissions, roles } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration roles: list all roles", + async fn() { + await truncateAll(); + await seedRoles([{ nom: "admin" }, { nom: "employee" }]); + const rows = await testDb.select().from(roles); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration roles: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb.insert(roles).values({ nom: "viewer" }) + .returning(); + assertExists(created.id); + assertEquals(created.nom, "viewer"); + const row = await testDb + .select() + .from(roles) + .where(eq(roles.id, created.id)) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration roles: assign and retrieve permissions", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "admin" }]); + await testDb.insert(permissions).values([ + { id: "student_read", nom: "Consulter les élèves" }, + { id: "student_write", nom: "Gérer les élèves" }, + ]); + await testDb.insert(rolePermissions).values([ + { idRole: role.id, idPermission: "student_read" }, + { idRole: role.id, idPermission: "student_write" }, + ]); + const perms = await testDb + .select() + .from(rolePermissions) + .where(eq(rolePermissions.idRole, role.id)); + assertEquals(perms.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration roles: update role nom", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "employee" }]); + const [updated] = await testDb + .update(roles) + .set({ nom: "teacher" }) + .where(eq(roles.id, role.id)) + .returning(); + assertEquals(updated.nom, "teacher"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration roles: reset permissions on update", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "admin" }]); + await testDb.insert(permissions).values([ + { id: "note_read", nom: "Consulter les notes" }, + { id: "note_write", nom: "Gérer les notes" }, + ]); + await testDb.insert(rolePermissions).values([ + { idRole: role.id, idPermission: "note_read" }, + ]); + // reset + await testDb.delete(rolePermissions).where( + eq(rolePermissions.idRole, role.id), + ); + await testDb.insert(rolePermissions).values([ + { idRole: role.id, idPermission: "note_write" }, + ]); + const perms = await testDb + .select() + .from(rolePermissions) + .where(eq(rolePermissions.idRole, role.id)); + assertEquals(perms.length, 1); + assertEquals(perms[0].idPermission, "note_write"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration roles: delete role removes it", + async fn() { + await truncateAll(); + const [role] = await seedRoles([{ nom: "moderator" }]); + await testDb.delete(roles).where(eq(roles.id, role.id)); + const row = await testDb + .select() + .from(roles) + .where(eq(roles.id, role.id)) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/students_test.ts b/tests/integration/students_test.ts new file mode 100644 index 0000000..bb53d6f --- /dev/null +++ b/tests/integration/students_test.ts @@ -0,0 +1,173 @@ +// #109 - Integration tests for /students endpoints +// Teste les opérations DB directement avec une vraie base de données + +import { assertEquals, assertExists } from "@std/assert"; +import { + seedPromotions, + seedStudents, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { students } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration students: list all students", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024" }]); + await seedStudents([ + { nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" }, + { nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" }, + ]); + + const rows = await testDb.select().from(students); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: filter by idPromo", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024" }, { id: "PEIP2-2024" }]); + await seedStudents([ + { nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" }, + { nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" }, + { nom: "Durand", prenom: "Claire", idPromo: "PEIP2-2024" }, + ]); + + const rows = await testDb + .select() + .from(students) + .where(eq(students.idPromo, "PEIP1-2024")); + assertEquals(rows.length, 2); + assertEquals(rows.every((s) => s.idPromo === "PEIP1-2024"), true); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: create and retrieve by numEtud", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + + const [created] = await testDb + .insert(students) + .values({ nom: "Leroy", prenom: "Paul", idPromo: "INFO3-2024" }) + .returning(); + + assertExists(created.numEtud); + + const row = await testDb + .select() + .from(students) + .where(eq(students.numEtud, created.numEtud)) + .then((r) => r[0] ?? null); + + assertExists(row); + assertEquals(row.nom, "Leroy"); + assertEquals(row.idPromo, "INFO3-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: get by numEtud returns null when not found", + async fn() { + await truncateAll(); + + const row = await testDb + .select() + .from(students) + .where(eq(students.numEtud, 999999)) + .then((r) => r[0] ?? null); + + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: update student fields", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }, { id: "INFO4-2024" }]); + const [s] = await seedStudents([ + { nom: "Petit", prenom: "Hugo", idPromo: "INFO3-2024" }, + ]); + + const [updated] = await testDb + .update(students) + .set({ nom: "Grand", idPromo: "INFO4-2024" }) + .where(eq(students.numEtud, s.numEtud)) + .returning(); + + assertEquals(updated.nom, "Grand"); + assertEquals(updated.idPromo, "INFO4-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: delete student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024" }]); + const [s] = await seedStudents([ + { nom: "Thomas", prenom: "Eva", idPromo: "INFO3-2024" }, + ]); + + await testDb.delete(students).where(eq(students.numEtud, s.numEtud)); + + const row = await testDb + .select() + .from(students) + .where(eq(students.numEtud, s.numEtud)) + .then((r) => r[0] ?? null); + + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: update non-existent student returns empty", + async fn() { + await truncateAll(); + + const result = await testDb + .update(students) + .set({ nom: "Ghost" }) + .where(eq(students.numEtud, 999999)) + .returning(); + + assertEquals(result.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration students: delete non-existent student returns empty", + async fn() { + await truncateAll(); + + const result = await testDb + .delete(students) + .where(eq(students.numEtud, 999999)) + .returning(); + + assertEquals(result.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/ue_modules_test.ts b/tests/integration/ue_modules_test.ts new file mode 100644 index 0000000..9aaab2a --- /dev/null +++ b/tests/integration/ue_modules_test.ts @@ -0,0 +1,183 @@ +// Integration tests for /ue-modules — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedModules, + seedPromotions, + seedUeModules, + seedUes, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { ueModules } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ue_modules: list all associations", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M2", idUE: ue.id, idPromo: "P1", coeff: 3.0 }, + ]); + const rows = await testDb.select().from(ueModules); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Maths" }]); + + const [created] = await testDb + .insert(ueModules) + .values({ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 4.0 }) + .returning(); + assertExists(created); + assertEquals(created.coeff, 4.0); + + const row = await testDb + .select() + .from(ueModules) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.coeff, 4.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: + "integration ue_modules: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(ueModules) + .where( + and( + eq(ueModules.idModule, "GHOST"), + eq(ueModules.idUE, 99), + eq(ueModules.idPromo, "GHOST"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 2.0, + }]); + await assertRejects(() => + testDb.insert(ueModules).values({ + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 5.0, + }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: update coeff", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 2.0, + }]); + + const [updated] = await testDb + .update(ueModules) + .set({ coeff: 6.0 }) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ) + .returning(); + assertEquals(updated.coeff, 6.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: delete removes the association", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 2.0, + }]); + + await testDb + .delete(ueModules) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ); + const row = await testDb + .select() + .from(ueModules) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/ues_test.ts b/tests/integration/ues_test.ts new file mode 100644 index 0000000..790330a --- /dev/null +++ b/tests/integration/ues_test.ts @@ -0,0 +1,90 @@ +// Integration tests for /ues — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { seedUes, testDb, truncateAll } from "../helpers/db_integration.ts"; +import { ues } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ues: list all UEs", + async fn() { + await truncateAll(); + await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]); + const rows = await testDb.select().from(ues); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb.insert(ues).values({ nom: "UE Physique" }) + .returning(); + assertExists(created); + assertExists(created.id); + assertEquals(created.nom, "UE Physique"); + + const row = await testDb.select().from(ues).where(eq(ues.id, created.id)) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.nom, "UE Physique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: get by id returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb.select().from(ues).where(eq(ues.id, 99999)).then(( + r, + ) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: update nom", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + const [updated] = await testDb.update(ues).set({ + nom: "UE Chimie organique", + }).where(eq(ues.id, ue.id)).returning(); + assertEquals(updated.nom, "UE Chimie organique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: delete removes the UE", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE à supprimer" }]); + await testDb.delete(ues).where(eq(ues.id, ue.id)); + const row = await testDb.select().from(ues).where(eq(ues.id, ue.id)).then(( + r, + ) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: nom is required (not null)", + async fn() { + await truncateAll(); + // deno-lint-ignore no-explicit-any + await assertRejects(() => testDb.insert(ues).values({ nom: null as any })); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/users_test.ts b/tests/integration/users_test.ts new file mode 100644 index 0000000..e0d5ae9 --- /dev/null +++ b/tests/integration/users_test.ts @@ -0,0 +1,58 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { + closeTestPool, + seedRoles, + seedUsers, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { users } from "$root/databases/schema.ts"; + +Deno.test({ + name: "integration: GET /users - DB round trip", + async fn() { + await truncateAll(); + + const [role] = await seedRoles([{ nom: "employee" }]); + await seedUsers([ + { id: "dupont.jean", nom: "Dupont", prenom: "Jean", idRole: role.id }, + { id: "martin.alice", nom: "Martin", prenom: "Alice", idRole: role.id }, + ]); + + const rows = await testDb.select().from(users); + assertEquals(rows.length, 2); + assertExists(rows.find((u) => u.id === "dupont.jean")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration: INSERT user and retrieve by id", + async fn() { + await truncateAll(); + + const [role] = await seedRoles([{ nom: "admin" }]); + const [created] = await testDb.insert(users).values({ + id: "durand.claire", + nom: "Durand", + prenom: "Claire", + idRole: role.id, + }).returning(); + + assertExists(created); + assertEquals(created.id, "durand.claire"); + assertEquals(created.nom, "Durand"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration: cleanup - close pool", + async fn() { + await closeTestPool(); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/unit/ajustements_test.ts b/tests/unit/ajustements_test.ts new file mode 100644 index 0000000..8820c23 --- /dev/null +++ b/tests/unit/ajustements_test.ts @@ -0,0 +1,224 @@ +// Unit tests for /ajustements endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Ajustement, ajustements } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ajustements: fixtures have correct shape", () => { + assertEquals(ajustements.length, 2); + assertEquals(typeof ajustements[0].numEtud, "number"); + assertEquals(typeof ajustements[0].idUE, "number"); + assertEquals(typeof ajustements[0].valeur, "number"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ajustements returns list", async () => { + mockFetch({ "/ajustements": ajustements }); + try { + const res = await fetch("http://localhost/api/ajustements"); + assertEquals(res.status, 200); + const data: Ajustement[] = await res.json(); + assertEquals(data.length, 2); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements?numEtud filters by student", async () => { + const filtered = ajustements.filter((a) => a.numEtud === 21212006); + mockFetch({ "/ajustements": filtered }); + try { + const res = await fetch( + "http://localhost/api/ajustements?numEtud=21212006", + ); + const data: Ajustement[] = await res.json(); + assertEquals(data.length, 1); + assertEquals(data[0].numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements?numEtud=NaN returns 400", async () => { + mockFetch({ "/ajustements": { status: 400 } }); + try { + const res = await fetch("http://localhost/api/ajustements?numEtud=abc"); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements creates ajustement (201) as employee", async () => { + const newAjust: Ajustement = { numEtud: 21212007, idUE: 2, valeur: 14.0 }; + mockFetch({ + "/ajustements": { method: "POST", status: 201, body: newAjust }, + }); + try { + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newAjust), + }); + assertEquals(res.status, 201); + const data: Ajustement = await res.json(); + assertEquals(data.numEtud, 21212007); + assertEquals(data.valeur, 14.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements 403 for non-employee", async () => { + mockFetch({ "/ajustements": { method: "POST", status: 403 } }); + try { + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + }); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements 400 on missing fields", async () => { + mockFetch({ "/ajustements": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud: 21212006 }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE returns ajustement (employee)", async () => { + mockFetch({ "/ajustements/21212006/1": ajustements[0] }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1"); + assertEquals(res.status, 200); + const data: Ajustement = await res.json(); + assertEquals(data.valeur, 13.25); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE 403 for non-employee", async () => { + mockFetch({ "/ajustements/21212006/1": { status: 403 } }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1"); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE 404 when not found", async () => { + mockFetch({ + "/ajustements/99999/9": { + status: 404, + body: { error: "Ajustement introuvable" }, + }, + }); + try { + const res = await fetch("http://localhost/api/ajustements/99999/9"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ajustements/:numEtud/:idUE updates valeur", async () => { + const updated: Ajustement = { ...ajustements[0], valeur: 18.0 }; + mockFetch({ + "/ajustements/21212006/1": { method: "PUT", status: 200, body: updated }, + }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ valeur: 18.0 }), + }); + assertEquals(res.status, 200); + const data: Ajustement = await res.json(); + assertEquals(data.valeur, 18.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ajustements/:numEtud/:idUE returns 204", async () => { + mockFetch({ "/ajustements/21212006/1": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find ajustement by composite key", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + const a = db.findOne( + "ajustements", + (a) => a.numEtud === 21212006 && a.idUE === 1, + ); + assertExists(a); + assertEquals(a.valeur, 13.25); +}); + +Deno.test("mock DB: filter ajustements by numEtud", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + const rows = db.findMany( + "ajustements", + (a) => a.numEtud === 21212006, + ); + assertEquals(rows.length, 1); +}); + +Deno.test("mock DB: insert ajustement", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.insert("ajustements", { + numEtud: 21212007, + idUE: 2, + valeur: 14.0, + }); + assertEquals(db.getTable("ajustements").length, 3); +}); + +Deno.test("mock DB: update ajustement valeur", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.updateWhere( + "ajustements", + (a) => a.numEtud === 21212006 && a.idUE === 1, + { valeur: 20.0 }, + ); + assertEquals( + db.findOne( + "ajustements", + (a) => a.numEtud === 21212006 && a.idUE === 1, + )?.valeur, + 20.0, + ); +}); + +Deno.test("mock DB: delete ajustement", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.deleteWhere( + "ajustements", + (a) => a.numEtud === 21212006 && a.idUE === 1, + ); + assertEquals(db.getTable("ajustements").length, 1); +}); diff --git a/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts new file mode 100644 index 0000000..d1e3b04 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,239 @@ +// Unit tests for /enseignements endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { enseignements } from "../helpers/fixtures.ts"; + +interface Enseignement { + idProf: string; + idModule: string; + idPromo: string; +} + +// --- Fixtures --- + +Deno.test("enseignements: fixtures have correct shape", () => { + assertEquals(enseignements.length, 3); + assertEquals(typeof enseignements[0].idModule, "string"); + assertEquals(typeof enseignements[0].idPromo, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: POST /enseignements creates enseignement (201) as employee", async () => { + const newEns: Enseignement = { + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }; + mockFetch({ + "/enseignements": { method: "POST", status: 201, body: newEns }, + }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newEns), + }); + assertEquals(res.status, 201); + const data: Enseignement = await res.json(); + assertEquals(data.idModule, "JIN702C"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 403 for non-employee", async () => { + mockFetch({ "/enseignements": { method: "POST", status: 403 } }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + }); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 400 on missing fields", async () => { + mockFetch({ "/enseignements": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idProf: "prof.dupont" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 409 on duplicate", async () => { + mockFetch({ + "/enseignements": { + method: "POST", + status: 409, + body: { error: "Cet enseignement existe déjà." }, + }, + }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 409); + const data = await res.json(); + assertExists(data.error); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", async () => { + const ens: Enseignement = { + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }; + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": ens }); + try { + const res = await fetch( + "http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", + ); + assertEquals(res.status, 200); + const data: Enseignement = await res.json(); + assertEquals(data.idProf, "prof.dupont"); + assertEquals(data.idModule, "JIN702C"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", async () => { + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": { status: 403 } }); + try { + const res = await fetch( + "http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", + ); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 404 when not found", async () => { + mockFetch({ + "/enseignements/ghost/GHOST/GHOST": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); + try { + const res = await fetch( + "http://localhost/api/enseignements/ghost/GHOST/GHOST", + ); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /enseignements/:idProf/:idModule/:idPromo returns 204 (employee)", async () => { + mockFetch({ + "/enseignements/prof.dupont/JIN702C/4AFISE25": { + method: "DELETE", + status: 204, + }, + }); + try { + const res = await fetch( + "http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", async () => { + mockFetch({ + "/enseignements/prof.dupont/JIN702C/4AFISE25": { + method: "DELETE", + status: 403, + }, + }); + try { + const res = await fetch( + "http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find enseignement by composite key", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + const e = db.findOne( + "enseignements", + (e) => e.idProf === "prof.dupont" && e.idModule === "JIN702C", + ); + assertExists(e); + assertEquals(e.idPromo, "4AFISE25/26"); +}); + +Deno.test("mock DB: insert enseignement", () => { + const db = createMockDb({ tables: { enseignements: [] } }); + db.insert("enseignements", { + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }); + assertEquals(db.getTable("enseignements").length, 1); +}); + +Deno.test("mock DB: delete enseignement", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + db.deleteWhere( + "enseignements", + (e) => e.idProf === "prof.dupont", + ); + assertEquals(db.getTable("enseignements").length, 1); +}); + +Deno.test("mock DB: filter enseignements by idModule", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "3AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + const rows = db.findMany( + "enseignements", + (e) => e.idModule === "JIN702C", + ); + assertEquals(rows.length, 2); +}); diff --git a/tests/unit/example_test.ts b/tests/unit/example_test.ts new file mode 100644 index 0000000..86618ec --- /dev/null +++ b/tests/unit/example_test.ts @@ -0,0 +1,266 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { + ERROR_CONFLICT, + ERROR_NOT_FOUND, + modules, + notes, + type Student, + students, +} from "../helpers/fixtures.ts"; +import { cleanupDOM, setupDOM } from "../helpers/render.ts"; + +// --- Fixtures --- + +Deno.test("fixtures - students match API shape", () => { + assertEquals(students.length, 3); + assertEquals(students[0].numEtud, 21212006); + assertEquals(students[0].idPromo, "4AFISE25/26"); + assertEquals(typeof students[0].idPromo, "string"); +}); + +Deno.test("fixtures - modules have string ids", () => { + assertEquals(modules[0].id, "JIN702C"); + assertEquals(typeof modules[0].id, "string"); +}); + +Deno.test("fixtures - notes use decimal values", () => { + assertEquals(notes[0].note, 15.5); + assertEquals(notes[0].idModule, "JIN702C"); +}); + +// --- Mock fetch simple (GET 200) --- + +Deno.test("mockFetch - GET returns mocked data", async () => { + mockFetch({ "/students": students }); + try { + const res = await fetch("http://localhost/api/students"); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.length, 3); + assertEquals(data[0].nom, "Dupont"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - returns 404 for unknown routes", async () => { + mockFetch({}); + try { + const res = await fetch("http://localhost/api/unknown"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock fetch avancé (méthodes + status codes) --- + +Deno.test("mockFetch - POST 201 created", async () => { + const newStudent = students[0]; + mockFetch({ + "/students": { method: "POST", status: 201, body: newStudent }, + }); + try { + const res = await fetch("http://localhost/api/students", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newStudent), + }); + assertEquals(res.status, 201); + const data = await res.json(); + assertEquals(data.numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - DELETE 204 no content", async () => { + mockFetch({ + "/students/21212006": { method: "DELETE", status: 204 }, + }); + try { + const res = await fetch("http://localhost/api/students/21212006", { + method: "DELETE", + }); + assertEquals(res.status, 204); + assertEquals(res.body, null); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - 404 error response", async () => { + mockFetch({ + "/students/99999": { status: 404, body: ERROR_NOT_FOUND }, + }); + try { + const res = await fetch("http://localhost/api/students/99999"); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - 409 conflict", async () => { + mockFetch({ + "/enseignements": { method: "POST", status: 409, body: ERROR_CONFLICT }, + }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 409); + } finally { + restoreFetch(); + } +}); + +// --- getFetchCalls --- + +Deno.test("getFetchCalls - tracks all intercepted calls", async () => { + mockFetch({ "/notes": notes }); + try { + await fetch("http://localhost/api/notes"); + await fetch("http://localhost/api/notes?numEtud=21212006"); + const calls = getFetchCalls(); + assertEquals(calls.length, 2); + assertEquals(calls[0].method, "GET"); + assertEquals(calls[1].url, "http://localhost/api/notes?numEtud=21212006"); + } finally { + restoreFetch(); + } +}); + +Deno.test("getFetchCalls - captures POST body", async () => { + mockFetch({ "/notes": { method: "POST", status: 201, body: notes[0] } }); + try { + await fetch("http://localhost/api/notes", { + method: "POST", + body: JSON.stringify(notes[0]), + }); + const calls = getFetchCalls(); + assertEquals(calls.length, 1); + assertEquals(calls[0].method, "POST"); + assertEquals((calls[0].body as { note: number }).note, 15.5); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mockDb - getTable returns seeded rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + assertEquals(db.getTable("students").length, 3); +}); + +Deno.test("mockDb - findOne by key", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const found = db.findOne("students", (r) => r.numEtud === 21212006); + assertExists(found); + assertEquals(found.nom, "Dupont"); +}); + +Deno.test("mockDb - findOne returns undefined for missing", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const found = db.findOne("students", (r) => r.numEtud === 99999); + assertEquals(found, undefined); +}); + +Deno.test("mockDb - insert adds a row", () => { + const db = createMockDb({ tables: { students: [] } }); + const newStudent: Student = { + numEtud: 21212099, + nom: "Test", + prenom: "User", + idPromo: "4AFISE25/26", + }; + db.insert("students", newStudent); + assertEquals(db.getTable("students").length, 1); + assertEquals( + db.findOne("students", (r) => r.numEtud === 21212099)?.nom, + "Test", + ); +}); + +Deno.test("mockDb - updateWhere modifies matching rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const updated = db.updateWhere( + "students", + (r) => r.numEtud === 21212006, + { prenom: "Marie" }, + ); + assertEquals(updated, 1); + assertEquals( + db.findOne("students", (r) => r.numEtud === 21212006)?.prenom, + "Marie", + ); +}); + +Deno.test("mockDb - deleteWhere removes matching rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const deleted = db.deleteWhere( + "students", + (r) => r.numEtud === 21212006, + ); + assertEquals(deleted, 1); + assertEquals(db.getTable("students").length, 2); +}); + +Deno.test("mockDb - findMany with filter", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const promo4 = db.findMany( + "students", + (r) => r.idPromo === "4AFISE25/26", + ); + assertEquals(promo4.length, 2); +}); + +Deno.test("mockDb - reset clears all tables", () => { + const db = createMockDb({ + tables: { students: [...students], notes: [...notes] }, + }); + db.reset(); + assertEquals(db.getTable("students").length, 0); + assertEquals(db.getTable("notes").length, 0); +}); + +Deno.test("mockDb - isolated between instances", () => { + const db1 = createMockDb({ tables: { students: [...students] } }); + const db2 = createMockDb({ tables: { students: [...students] } }); + db1.deleteWhere("students", () => true); + assertEquals(db1.getTable("students").length, 0); + assertEquals(db2.getTable("students").length, 3); +}); + +// --- happy-dom --- + +Deno.test({ + name: "happy-dom - document is available after setup", + sanitizeResources: false, + sanitizeOps: false, + fn() { + setupDOM(); + try { + const doc = globalThis.document; + assertExists(doc); + + const div = doc.createElement("div"); + div.textContent = "hello"; + doc.body.appendChild(div); + + assertEquals(doc.body.textContent, "hello"); + } finally { + cleanupDOM(); + } + }, +}); diff --git a/tests/unit/modules_test.ts b/tests/unit/modules_test.ts new file mode 100644 index 0000000..e94cdc4 --- /dev/null +++ b/tests/unit/modules_test.ts @@ -0,0 +1,171 @@ +// #113 - Unit tests for /modules endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Module, modules } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("modules: fixtures have correct shape", () => { + assertEquals(modules.length, 3); + assertEquals(typeof modules[0].id, "string"); + assertEquals(typeof modules[0].nom, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /modules returns list", async () => { + mockFetch({ "/modules": modules }); + try { + const res = await fetch("http://localhost/api/modules"); + assertEquals(res.status, 200); + const data: Module[] = await res.json(); + assertEquals(data.length, 3); + assertExists(data.find((m) => m.id === "JIN702C")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /modules/:id returns one module", async () => { + mockFetch({ "/modules/JIN702C": modules[0] }); + try { + const res = await fetch("http://localhost/api/modules/JIN702C"); + assertEquals(res.status, 200); + const data: Module = await res.json(); + assertEquals(data.id, "JIN702C"); + assertEquals(data.nom, "Optimisation"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /modules/:id 404 when not found", async () => { + mockFetch({ + "/modules/UNKNOWN": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); + try { + const res = await fetch("http://localhost/api/modules/UNKNOWN"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /modules creates module (201)", async () => { + const newModule: Module = { id: "NEW101", nom: "Nouveau Module" }; + mockFetch({ "/modules": { method: "POST", status: 201, body: newModule } }); + try { + const res = await fetch("http://localhost/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newModule), + }); + assertEquals(res.status, 201); + const data: Module = await res.json(); + assertEquals(data.id, "NEW101"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /modules 409 on duplicate id", async () => { + mockFetch({ + "/modules": { + method: "POST", + status: 409, + body: { error: "Un module avec cet identifiant existe déjà" }, + }, + }); + try { + const res = await fetch("http://localhost/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(modules[0]), + }); + assertEquals(res.status, 409); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /modules 400 on missing fields", async () => { + mockFetch({ "/modules": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "X" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /modules/:id updates nom", async () => { + const updated: Module = { id: "JIN702C", nom: "Optimisation avancée" }; + mockFetch({ + "/modules/JIN702C": { method: "PUT", status: 200, body: updated }, + }); + try { + const res = await fetch("http://localhost/api/modules/JIN702C", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "Optimisation avancée" }), + }); + assertEquals(res.status, 200); + const data: Module = await res.json(); + assertEquals(data.nom, "Optimisation avancée"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /modules/:id returns 204", async () => { + mockFetch({ "/modules/JIN702C": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/modules/JIN702C", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find module by id", () => { + const db = createMockDb({ tables: { modules: [...modules] } }); + const m = db.findOne("modules", (m) => m.id === "JIN702C"); + assertExists(m); + assertEquals(m.nom, "Optimisation"); +}); + +Deno.test("mock DB: insert module", () => { + const db = createMockDb({ tables: { modules: [...modules] } }); + db.insert("modules", { id: "NEW101", nom: "Nouveau" }); + assertEquals(db.getTable("modules").length, 4); +}); + +Deno.test("mock DB: update module nom", () => { + const db = createMockDb({ tables: { modules: [...modules] } }); + db.updateWhere("modules", (m) => m.id === "JIN702C", { + nom: "Updated", + }); + assertEquals( + db.findOne("modules", (m) => m.id === "JIN702C")?.nom, + "Updated", + ); +}); + +Deno.test("mock DB: delete module", () => { + const db = createMockDb({ tables: { modules: [...modules] } }); + db.deleteWhere("modules", (m) => m.id === "JIN702C"); + assertEquals(db.getTable("modules").length, 2); +}); diff --git a/tests/unit/notes_test.ts b/tests/unit/notes_test.ts new file mode 100644 index 0000000..9e13794 --- /dev/null +++ b/tests/unit/notes_test.ts @@ -0,0 +1,224 @@ +// Unit tests for /notes endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Note, notes } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("notes: fixtures have correct shape", () => { + assertEquals(notes.length, 4); + assertEquals(typeof notes[0].note, "number"); + assertEquals(typeof notes[0].numEtud, "number"); + assertEquals(typeof notes[0].idModule, "string"); +}); + +Deno.test("notes: fixtures use decimal values", () => { + assertEquals(notes[0].note, 15.5); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /notes returns list", async () => { + mockFetch({ "/notes": notes }); + try { + const res = await fetch("http://localhost/api/notes"); + assertEquals(res.status, 200); + const data: Note[] = await res.json(); + assertEquals(data.length, 4); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?numEtud filters by student", async () => { + const filtered = notes.filter((n) => n.numEtud === 21212006); + mockFetch({ "/notes": filtered }); + try { + const res = await fetch("http://localhost/api/notes?numEtud=21212006"); + const data: Note[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((n) => n.numEtud === 21212006), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?idModule filters by module", async () => { + const filtered = notes.filter((n) => n.idModule === "JIN702C"); + mockFetch({ "/notes": filtered }); + try { + const res = await fetch("http://localhost/api/notes?idModule=JIN702C"); + const data: Note[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((n) => n.idModule === "JIN702C"), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?numEtud=NaN returns 400", async () => { + mockFetch({ "/notes": { status: 400 } }); + try { + const res = await fetch("http://localhost/api/notes?numEtud=abc"); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /notes creates note (201)", async () => { + const newNote: Note = { note: 14.0, numEtud: 21212006, idModule: "JIN704C" }; + mockFetch({ "/notes": { method: "POST", status: 201, body: newNote } }); + try { + const res = await fetch("http://localhost/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newNote), + }); + assertEquals(res.status, 201); + const data: Note = await res.json(); + assertEquals(data.note, 14.0); + assertEquals(data.numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /notes 400 on missing fields", async () => { + mockFetch({ "/notes": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud: 21212006 }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes/:numEtud/:idModule returns note", async () => { + mockFetch({ "/notes/21212006/JIN702C": notes[0] }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C"); + assertEquals(res.status, 200); + const data: Note = await res.json(); + assertEquals(data.note, 15.5); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes/:numEtud/:idModule 404 when not found", async () => { + mockFetch({ + "/notes/99999/GHOST": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); + try { + const res = await fetch("http://localhost/api/notes/99999/GHOST"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /notes/:numEtud/:idModule updates note", async () => { + const updated: Note = { ...notes[0], note: 17.0 }; + mockFetch({ + "/notes/21212006/JIN702C": { method: "PUT", status: 200, body: updated }, + }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note: 17.0 }), + }); + assertEquals(res.status, 200); + const data: Note = await res.json(); + assertEquals(data.note, 17.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /notes/:numEtud/:idModule returns 204", async () => { + mockFetch({ "/notes/21212006/JIN702C": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /notes/:numEtud/:idModule 404 when not found", async () => { + mockFetch({ "/notes/99999/GHOST": { method: "DELETE", status: 404 } }); + try { + const res = await fetch("http://localhost/api/notes/99999/GHOST", { + method: "DELETE", + }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find note by composite key", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + const n = db.findOne( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + ); + assertExists(n); + assertEquals(n.note, 15.5); +}); + +Deno.test("mock DB: filter notes by numEtud", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + const rows = db.findMany("notes", (n) => n.numEtud === 21212006); + assertEquals(rows.length, 2); +}); + +Deno.test("mock DB: insert note", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.insert("notes", { + note: 10.0, + numEtud: 21212006, + idModule: "JIN704C", + }); + assertEquals(db.getTable("notes").length, 5); +}); + +Deno.test("mock DB: update note value", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.updateWhere( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + { note: 20.0 }, + ); + assertEquals( + db.findOne( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + )?.note, + 20.0, + ); +}); + +Deno.test("mock DB: delete note", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.deleteWhere( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + ); + assertEquals(db.getTable("notes").length, 3); +}); diff --git a/tests/unit/permissions_test.ts b/tests/unit/permissions_test.ts new file mode 100644 index 0000000..c3a8052 --- /dev/null +++ b/tests/unit/permissions_test.ts @@ -0,0 +1,65 @@ +// #115 - Unit tests for GET /permissions + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; + +interface Permission { + id: string; + nom: string; +} + +const EXPECTED_PERMISSIONS: Permission[] = [ + { id: "student_read", nom: "Consulter les élèves" }, + { id: "student_write", nom: "Gérer les élèves" }, + { id: "note_read", nom: "Consulter les notes" }, + { id: "note_write", nom: "Gérer les notes" }, + { id: "module_read", nom: "Consulter les modules" }, + { id: "module_write", nom: "Gérer les modules" }, + { id: "user_read", nom: "Consulter les utilisateurs" }, + { id: "user_write", nom: "Gérer les utilisateurs" }, + { id: "role_write", nom: "Gérer les rôles" }, +]; + +Deno.test("permissions: known permission ids", () => { + const ids = EXPECTED_PERMISSIONS.map((p) => p.id); + assertEquals(ids.includes("student_read"), true); + assertEquals(ids.includes("student_write"), true); + assertEquals(ids.includes("note_read"), true); + assertEquals(ids.includes("role_write"), true); + assertEquals(ids.length, 9); +}); + +Deno.test("permissions: all permissions have string id and nom", () => { + for (const p of EXPECTED_PERMISSIONS) { + assertEquals(typeof p.id, "string"); + assertEquals(typeof p.nom, "string"); + } +}); + +Deno.test("mock API: GET /permissions returns all permissions", async () => { + mockFetch({ "/permissions": EXPECTED_PERMISSIONS }); + try { + const res = await fetch("http://localhost/api/permissions"); + assertEquals(res.status, 200); + const data: Permission[] = await res.json(); + assertEquals(data.length, 9); + assertExists(data.find((p) => p.id === "student_read")); + assertExists(data.find((p) => p.id === "role_write")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /permissions - each permission has id and nom", async () => { + mockFetch({ "/permissions": EXPECTED_PERMISSIONS }); + try { + const res = await fetch("http://localhost/api/permissions"); + const data: Permission[] = await res.json(); + for (const p of data) { + assertExists(p.id); + assertExists(p.nom); + } + } finally { + restoreFetch(); + } +}); diff --git a/tests/unit/promotions_test.ts b/tests/unit/promotions_test.ts new file mode 100644 index 0000000..b725bc5 --- /dev/null +++ b/tests/unit/promotions_test.ts @@ -0,0 +1,160 @@ +// #110 - Unit tests for /promotions endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Promotion, promotions } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("promotions: fixtures have correct shape", () => { + assertEquals(promotions.length, 3); + assertEquals(typeof promotions[0].idPromo, "string"); + assertEquals(typeof promotions[0].annee, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /promotions returns list", async () => { + mockFetch({ "/promotions": promotions }); + try { + const res = await fetch("http://localhost/api/promotions"); + assertEquals(res.status, 200); + const data: Promotion[] = await res.json(); + assertEquals(data.length, 3); + assertExists(data.find((p) => p.idPromo === "4AFISE25/26")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /promotions/:id returns one", async () => { + mockFetch({ "/promotions/4AFISE25%2F26": promotions[0] }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26"); + assertEquals(res.status, 200); + const data: Promotion = await res.json(); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /promotions/:id 404 when not found", async () => { + mockFetch({ + "/promotions/UNKNOWN": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); + try { + const res = await fetch("http://localhost/api/promotions/UNKNOWN"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /promotions creates promotion (201)", async () => { + const newPromo: Promotion = { idPromo: "NEW2025", annee: "2025" }; + mockFetch({ "/promotions": { method: "POST", status: 201, body: newPromo } }); + try { + const res = await fetch("http://localhost/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: "NEW2025", annee: "2025" }), + }); + assertEquals(res.status, 201); + const data: Promotion = await res.json(); + assertEquals(data.idPromo, "NEW2025"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /promotions 400 on missing fields", async () => { + mockFetch({ "/promotions": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: "NEW2025" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /promotions/:id updates promotion", async () => { + const updated = { idPromo: "4AFISE25/26", annee: "2026" }; + mockFetch({ + "/promotions/4AFISE25%2F26": { method: "PUT", status: 200, body: updated }, + }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annee: "2026" }), + }); + assertEquals(res.status, 200); + const data: Promotion = await res.json(); + assertEquals(data.annee, "2026"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /promotions/:id returns 204", async () => { + mockFetch({ "/promotions/4AFISE25%2F26": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find promotion by idPromo", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + const p = db.findOne( + "promotions", + (r) => r.idPromo === "4AFISE25/26", + ); + assertExists(p); + assertEquals(p.annee, "2025"); +}); + +Deno.test("mock DB: insert promotion", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + db.insert("promotions", { idPromo: "NEW2025", annee: "2025" }); + assertEquals(db.getTable("promotions").length, 4); +}); + +Deno.test("mock DB: update promotion annee", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + db.updateWhere( + "promotions", + (p) => p.idPromo === "4AFISE25/26", + { annee: "2026" }, + ); + assertEquals( + db.findOne("promotions", (p) => p.idPromo === "4AFISE25/26") + ?.annee, + "2026", + ); +}); + +Deno.test("mock DB: delete promotion", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + const count = db.deleteWhere( + "promotions", + (p) => p.idPromo === "4AFISE25/26", + ); + assertEquals(count, 1); + assertEquals(db.getTable("promotions").length, 2); +}); diff --git a/tests/unit/roles_test.ts b/tests/unit/roles_test.ts new file mode 100644 index 0000000..eeae55e --- /dev/null +++ b/tests/unit/roles_test.ts @@ -0,0 +1,159 @@ +// #112 - Unit tests for /roles endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; + +interface Role { + id: number; + nom: string; + permissions: string[]; +} + +const roles: Role[] = [ + { id: 1, nom: "admin", permissions: ["student_read", "student_write"] }, + { id: 2, nom: "employee", permissions: ["student_read"] }, +]; + +// --- Fixtures --- + +Deno.test("roles: fixtures have correct shape", () => { + assertEquals(roles.length, 2); + assertEquals(typeof roles[0].id, "number"); + assertEquals(typeof roles[0].nom, "string"); + assertEquals(Array.isArray(roles[0].permissions), true); +}); + +Deno.test("roles: permissions are strings", () => { + assertEquals(roles[0].permissions.every((p) => typeof p === "string"), true); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /roles returns list with permissions", async () => { + mockFetch({ "/roles": roles }); + try { + const res = await fetch("http://localhost/api/roles"); + assertEquals(res.status, 200); + const data: Role[] = await res.json(); + assertEquals(data.length, 2); + assertExists(data.find((r) => r.nom === "admin")); + assertEquals(data[0].permissions.length, 2); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /roles/:id returns role", async () => { + mockFetch({ "/roles/1": roles[0] }); + try { + const res = await fetch("http://localhost/api/roles/1"); + assertEquals(res.status, 200); + const data: Role = await res.json(); + assertEquals(data.nom, "admin"); + assertEquals(data.permissions.length, 2); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /roles/:id 404 when not found", async () => { + mockFetch({ + "/roles/99": { status: 404, body: { error: "Ressource introuvable" } }, + }); + try { + const res = await fetch("http://localhost/api/roles/99"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /roles creates role (201)", async () => { + const newRole: Role = { id: 3, nom: "viewer", permissions: [] }; + mockFetch({ "/roles": { method: "POST", status: 201, body: newRole } }); + try { + const res = await fetch("http://localhost/api/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "viewer" }), + }); + assertEquals(res.status, 201); + const data: Role = await res.json(); + assertEquals(data.nom, "viewer"); + assertEquals(data.permissions.length, 0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /roles 400 on missing nom", async () => { + mockFetch({ "/roles": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /roles/:id updates role and permissions", async () => { + const updated: Role = { id: 2, nom: "teacher", permissions: ["note_read"] }; + mockFetch({ "/roles/2": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/roles/2", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "teacher", permissions: ["note_read"] }), + }); + assertEquals(res.status, 200); + const data: Role = await res.json(); + assertEquals(data.nom, "teacher"); + assertEquals(data.permissions, ["note_read"]); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /roles/:id returns 204", async () => { + mockFetch({ "/roles/2": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/roles/2", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find role by id", () => { + const db = createMockDb({ tables: { roles: [...roles] } }); + const r = db.findOne("roles", (r) => r.id === 1); + assertExists(r); + assertEquals(r.nom, "admin"); +}); + +Deno.test("mock DB: insert role", () => { + const db = createMockDb({ tables: { roles: [...roles] } }); + db.insert("roles", { id: 3, nom: "viewer", permissions: [] }); + assertEquals(db.getTable("roles").length, 3); +}); + +Deno.test("mock DB: update role nom", () => { + const db = createMockDb({ tables: { roles: [...roles] } }); + db.updateWhere("roles", (r) => r.id === 2, { nom: "teacher" }); + assertEquals(db.findOne("roles", (r) => r.id === 2)?.nom, "teacher"); +}); + +Deno.test("mock DB: delete role", () => { + const db = createMockDb({ tables: { roles: [...roles] } }); + db.deleteWhere("roles", (r) => r.id === 1); + assertEquals(db.getTable("roles").length, 1); +}); diff --git a/tests/unit/students_test.ts b/tests/unit/students_test.ts new file mode 100644 index 0000000..ded2ff2 --- /dev/null +++ b/tests/unit/students_test.ts @@ -0,0 +1,216 @@ +// #109 - Unit tests for /students endpoints +// Tests purs : fixtures, mock API, mock DB — aucun appel réseau réel + +import { assertEquals, assertExists } from "@std/assert"; +import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Student, students } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("students: fixtures have correct shape", () => { + assertEquals(students.length, 3); + assertEquals(typeof students[0].numEtud, "number"); + assertEquals(typeof students[0].nom, "string"); + assertEquals(typeof students[0].prenom, "string"); + assertEquals(typeof students[0].idPromo, "string"); +}); + +Deno.test("students: two students belong to the same promo", () => { + const promo4 = students.filter((s) => s.idPromo === "4AFISE25/26"); + assertEquals(promo4.length, 2); +}); + +// --- Mock API - GET /students --- + +Deno.test("mock API: GET /students returns list", async () => { + mockFetch({ "/students": students }); + try { + const res = await fetch("http://localhost/api/students"); + assertEquals(res.status, 200); + const data: Student[] = await res.json(); + assertEquals(data.length, 3); + assertExists(data.find((s) => s.nom === "Dupont")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /students?idPromo filters by promo", async () => { + const filtered = students.filter((s) => s.idPromo === "4AFISE25/26"); + mockFetch({ "/students": filtered }); + try { + const res = await fetch( + "http://localhost/api/students?idPromo=4AFISE25/26", + ); + const data: Student[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((s) => s.idPromo === "4AFISE25/26"), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /students/:numEtud returns one student", async () => { + mockFetch({ "/students/21212006": students[0] }); + try { + const res = await fetch("http://localhost/api/students/21212006"); + assertEquals(res.status, 200); + const data: Student = await res.json(); + assertEquals(data.numEtud, 21212006); + assertEquals(data.nom, "Dupont"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /students/:numEtud 404 when not found", async () => { + mockFetch({ + "/students/99999": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); + try { + const res = await fetch("http://localhost/api/students/99999"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /students creates student", async () => { + const newStudent = students[0]; + mockFetch({ "/students": { method: "POST", status: 201, body: newStudent } }); + try { + const res = await fetch("http://localhost/api/students", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: "Dupont", + prenom: "Jean", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 201); + const data: Student = await res.json(); + assertEquals(data.nom, "Dupont"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /students/:numEtud updates student", async () => { + const updated = { ...students[0], nom: "Dupont-Modifié" }; + mockFetch({ + "/students/21212006": { method: "PUT", status: 200, body: updated }, + }); + try { + const res = await fetch("http://localhost/api/students/21212006", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: "Dupont-Modifié", + prenom: "Jean", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 200); + const data: Student = await res.json(); + assertEquals(data.nom, "Dupont-Modifié"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /students/:numEtud returns 204", async () => { + mockFetch({ "/students/21212006": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/students/21212006", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /students 400 on missing fields", async () => { + mockFetch({ "/students": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/students", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "Test" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find student by numEtud", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const s = db.findOne("students", (r) => r.numEtud === 21212006); + assertExists(s); + assertEquals(s.nom, "Dupont"); +}); + +Deno.test("mock DB: filter students by idPromo", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const rows = db.findMany( + "students", + (s) => s.idPromo === "4AFISE25/26", + ); + assertEquals(rows.length, 2); +}); + +Deno.test("mock DB: insert student increments count", () => { + const db = createMockDb({ tables: { students: [...students] } }); + db.insert("students", { + numEtud: 21212099, + nom: "Test", + prenom: "Ing", + idPromo: "4AFISE25/26", + }); + assertEquals(db.getTable("students").length, 4); +}); + +Deno.test("mock DB: update student nom", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const count = db.updateWhere( + "students", + (s) => s.numEtud === 21212006, + { nom: "Nouveau" }, + ); + assertEquals(count, 1); + assertEquals( + db.findOne("students", (s) => s.numEtud === 21212006)?.nom, + "Nouveau", + ); +}); + +Deno.test("mock DB: delete student removes exactly one", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const count = db.deleteWhere( + "students", + (s) => s.numEtud === 21212006, + ); + assertEquals(count, 1); + assertEquals(db.getTable("students").length, 2); +}); + +Deno.test("mock API: getFetchCalls tracks student requests", async () => { + mockFetch({ "/students": students }); + try { + await fetch("http://localhost/api/students"); + await fetch("http://localhost/api/students?idPromo=4AFISE25/26"); + const calls = getFetchCalls(); + assertEquals(calls.length, 2); + assertEquals(calls[0].method, "GET"); + } finally { + restoreFetch(); + } +}); diff --git a/tests/unit/ue_modules_test.ts b/tests/unit/ue_modules_test.ts new file mode 100644 index 0000000..7b2761d --- /dev/null +++ b/tests/unit/ue_modules_test.ts @@ -0,0 +1,222 @@ +// Unit tests for /ue-modules endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type UeModule, ueModules } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ue_modules: fixtures have correct shape", () => { + assertEquals(ueModules.length, 3); + assertEquals(typeof ueModules[0].idModule, "string"); + assertEquals(typeof ueModules[0].idUE, "number"); + assertEquals(typeof ueModules[0].idPromo, "string"); + assertEquals(typeof ueModules[0].coeff, "number"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ue-modules returns list", async () => { + mockFetch({ "/ue-modules": ueModules }); + try { + const res = await fetch("http://localhost/api/ue-modules"); + assertEquals(res.status, 200); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 3); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules?idPromo filters by promo", async () => { + const filtered = ueModules.filter((u) => u.idPromo === "4AFISE25/26"); + mockFetch({ "/ue-modules": filtered }); + try { + const res = await fetch( + "http://localhost/api/ue-modules?idPromo=4AFISE25%2F26", + ); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((u) => u.idPromo === "4AFISE25/26"), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules?idUE filters by UE", async () => { + const filtered = ueModules.filter((u) => u.idUE === 1); + mockFetch({ "/ue-modules": filtered }); + try { + const res = await fetch("http://localhost/api/ue-modules?idUE=1"); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((u) => u.idUE === 1), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ue-modules creates association (201)", async () => { + const newUeModule: UeModule = { + idModule: "JIN705C", + idUE: 2, + idPromo: "3AFISE25/26", + coeff: 3.0, + }; + mockFetch({ + "/ue-modules": { method: "POST", status: 201, body: newUeModule }, + }); + try { + const res = await fetch("http://localhost/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newUeModule), + }); + assertEquals(res.status, 201); + const data: UeModule = await res.json(); + assertEquals(data.idModule, "JIN705C"); + assertEquals(data.coeff, 3.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ue-modules 400 on missing fields", async () => { + mockFetch({ "/ue-modules": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idModule: "X" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo returns association (employee)", async () => { + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": ueModules[0] }); + try { + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + ); + assertEquals(res.status, 200); + const data: UeModule = await res.json(); + assertEquals(data.coeff, 3.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", async () => { + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": { status: 403 } }); + try { + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + ); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ue-modules/:idModule/:idUE/:idPromo updates coeff", async () => { + const updated: UeModule = { ...ueModules[0], coeff: 5.0 }; + mockFetch({ + "/ue-modules/JIN702C/1/4AFISE25": { + method: "PUT", + status: 200, + body: updated, + }, + }); + try { + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coeff: 5.0 }), + }, + ); + assertEquals(res.status, 200); + const data: UeModule = await res.json(); + assertEquals(data.coeff, 5.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ue-modules/:idModule/:idUE/:idPromo returns 204", async () => { + mockFetch({ + "/ue-modules/JIN702C/1/4AFISE25": { method: "DELETE", status: 204 }, + }); + try { + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + { method: "DELETE" }, + ); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find ue-module by composite key", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + const u = db.findOne( + "ueModules", + (u) => + u.idModule === "JIN702C" && u.idUE === 1 && u.idPromo === "4AFISE25/26", + ); + assertExists(u); + assertEquals(u.coeff, 3.0); +}); + +Deno.test("mock DB: filter ue-modules by promo", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + const rows = db.findMany( + "ueModules", + (u) => u.idPromo === "4AFISE25/26", + ); + assertEquals(rows.length, 2); +}); + +Deno.test("mock DB: insert ue-module", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.insert("ueModules", { + idModule: "JIN705C", + idUE: 2, + idPromo: "3AFISE25/26", + coeff: 1.5, + }); + assertEquals(db.getTable("ueModules").length, 4); +}); + +Deno.test("mock DB: update ue-module coeff", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.updateWhere( + "ueModules", + (u) => u.idModule === "JIN702C" && u.idUE === 1, + { coeff: 6.0 }, + ); + assertEquals( + db.findOne( + "ueModules", + (u) => u.idModule === "JIN702C" && u.idUE === 1, + )?.coeff, + 6.0, + ); +}); + +Deno.test("mock DB: delete ue-module", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.deleteWhere( + "ueModules", + (u) => u.idModule === "JIN702C" && u.idUE === 1, + ); + assertEquals(db.getTable("ueModules").length, 2); +}); diff --git a/tests/unit/ues_test.ts b/tests/unit/ues_test.ts new file mode 100644 index 0000000..f823f7d --- /dev/null +++ b/tests/unit/ues_test.ts @@ -0,0 +1,164 @@ +// Unit tests for /ues endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type UE, ues } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ues: fixtures have correct shape", () => { + assertEquals(ues.length, 2); + assertEquals(typeof ues[0].id, "number"); + assertEquals(typeof ues[0].nom, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ues returns list", async () => { + mockFetch({ "/ues": ues }); + try { + const res = await fetch("http://localhost/api/ues"); + assertEquals(res.status, 200); + const data: UE[] = await res.json(); + assertEquals(data.length, 2); + assertExists(data.find((u) => u.nom === "UE Informatique")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ues/:id returns one UE", async () => { + mockFetch({ "/ues/1": ues[0] }); + try { + const res = await fetch("http://localhost/api/ues/1"); + assertEquals(res.status, 200); + const data: UE = await res.json(); + assertEquals(data.id, 1); + assertEquals(data.nom, "UE Informatique"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ues/:id 404 when not found", async () => { + mockFetch({ + "/ues/99": { status: 404, body: { error: "Ressource introuvable" } }, + }); + try { + const res = await fetch("http://localhost/api/ues/99"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ues creates UE (201)", async () => { + const newUE: UE = { id: 3, nom: "UE Physique" }; + mockFetch({ "/ues": { method: "POST", status: 201, body: newUE } }); + try { + const res = await fetch("http://localhost/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "UE Physique" }), + }); + assertEquals(res.status, 201); + const data: UE = await res.json(); + assertEquals(data.nom, "UE Physique"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ues 400 on missing nom", async () => { + mockFetch({ "/ues": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ues/:id updates nom", async () => { + const updated: UE = { id: 1, nom: "UE Informatique avancée" }; + mockFetch({ "/ues/1": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/ues/1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "UE Informatique avancée" }), + }); + assertEquals(res.status, 200); + const data: UE = await res.json(); + assertEquals(data.nom, "UE Informatique avancée"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ues/:id 404 when not found", async () => { + mockFetch({ "/ues/99": { method: "PUT", status: 404 } }); + try { + const res = await fetch("http://localhost/api/ues/99", { + method: "PUT", + body: JSON.stringify({ nom: "X" }), + }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ues/:id returns 204", async () => { + mockFetch({ "/ues/1": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/ues/1", { method: "DELETE" }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ues/:id 404 when not found", async () => { + mockFetch({ "/ues/99": { method: "DELETE", status: 404 } }); + try { + const res = await fetch("http://localhost/api/ues/99", { + method: "DELETE", + }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find UE by id", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + const u = db.findOne("ues", (u) => u.id === 1); + assertExists(u); + assertEquals(u.nom, "UE Informatique"); +}); + +Deno.test("mock DB: insert UE", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.insert("ues", { id: 3, nom: "UE Physique" }); + assertEquals(db.getTable("ues").length, 3); +}); + +Deno.test("mock DB: update UE nom", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.updateWhere("ues", (u) => u.id === 1, { nom: "Updated" }); + assertEquals(db.findOne("ues", (u) => u.id === 1)?.nom, "Updated"); +}); + +Deno.test("mock DB: delete UE", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.deleteWhere("ues", (u) => u.id === 1); + assertEquals(db.getTable("ues").length, 1); +}); diff --git a/toolbox/compile.sh b/toolbox/compile.sh new file mode 100755 index 0000000..2b5022b --- /dev/null +++ b/toolbox/compile.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -e + +# Default output path +OUTPUT_PATH="${HOME}/.deno/bin/pmpr" + +# Ensure directory exists +mkdir -p "$(dirname "$OUTPUT_PATH")" + +# Check if we are on a system that needs patching (like NixOS) +IS_NIXOS=false +if [ "$(uname)" = "Linux" ]; then + if [ ! -f /lib64/ld-linux-x86-64.so.2 ] || ls -l /lib64/ld-linux-x86-64.so.2 | grep -q "stub-ld"; then + IS_NIXOS=true + fi +fi + +if [ "$IS_NIXOS" = true ]; then + echo "NixOS detected. Creating a wrapper script instead of a compiled binary to avoid linking issues with Deno." + # Use absolute paths for config and script to make it work from anywhere + PROJECT_ROOT="$(pwd)" + cat > "$OUTPUT_PATH" <