From 5932b8c2cd3536475e7abf90dc8bc7ebab899f46 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:30:48 +0200 Subject: [PATCH 001/103] docs(env): add postgres env variables --- env.template | 6 ++++++ 1 file changed, 6 insertions(+) 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 -- 2.52.0 From 4a2a0a36816ab64820331b253c476891ff3f6d35 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:33:38 +0200 Subject: [PATCH 002/103] chore: add dependencies for dotenv, drizzle-orm, pg and dev deps Set up environment config and database ORM --- bun.lock | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 12 +++ 2 files changed, 245 insertions(+) create mode 100644 bun.lock create mode 100644 package.json 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/package.json b/package.json new file mode 100644 index 0000000..4cf5711 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "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" + } +} \ No newline at end of file -- 2.52.0 From 33b8c178f29e1643b8fa928696cc4154c8709d9e Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:41:11 +0200 Subject: [PATCH 003/103] feat(db): add PostgreSQL connection and schema definitions --- databases/db.ts | 14 ++++++++++++++ databases/schema.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 databases/db.ts create mode 100644 databases/schema.ts diff --git a/databases/db.ts b/databases/db.ts new file mode 100644 index 0000000..b405c39 --- /dev/null +++ b/databases/db.ts @@ -0,0 +1,14 @@ +import { drizzle } from "npm:drizzle-orm/node-postgres"; +import pg from "npm:pg"; + +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/schema.ts b/databases/schema.ts new file mode 100644 index 0000000..1d7632e --- /dev/null +++ b/databases/schema.ts @@ -0,0 +1,32 @@ +import { + date, + integer, + pgTable, + serial, + text, +} from "npm:drizzle-orm/pg-core"; + +export const promotions = pgTable("promotions", { + id: serial("id").primaryKey(), + endyear: integer("endyear"), + current: integer("current"), +}); + +export const students = pgTable("students", { + userId: text("userId").primaryKey(), + firstName: text("firstName"), + lastName: text("lastName"), + mail: text("mail"), + promotionId: integer("promotionId").references(() => promotions.id), +}); + +export const mobility = pgTable("mobility", { + id: serial("id").primaryKey(), + studentId: text("studentId").references(() => students.userId), + startDate: date("startDate"), + endDate: date("endDate"), + weeksCount: integer("weeksCount"), + destinationCountry: text("destinationCountry"), + destinationName: text("destinationName"), + mobilityStatus: text("mobilityStatus").default("N/A"), +}); -- 2.52.0 From 4949bdce5d86dd5a78eed25c8dc2ead18e0ac3fe Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:41:52 +0200 Subject: [PATCH 004/103] chore(drizzle): add config for drizzle-kit migrations --- drizzle.config.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 drizzle.config.ts diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..22b4b99 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./databases/schema.ts", + out: "./databases/migrations", + dbCredentials: { + host: process.env.POSTGRES_HOST!, + port: Number(process.env.POSTGRES_PORT ?? 5432), + user: process.env.POSTGRES_USER!, + password: process.env.POSTGRES_PASS!, + database: process.env.POSTGRES_DB!, + }, +}); -- 2.52.0 From 9636242b42fb6dce0d61a3f97b75a0942825b1de Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:43:29 +0200 Subject: [PATCH 005/103] refactor(mobility): switch to Drizzle ORM and remove raw SQLite usage - replace Database with db instance - use schema imports for tables - use db.select, db.insert, onConflictDoUpdate - remove manual connection handling and console logs - improve type safety and maintainability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(students): migrate to Drizzle ORM and async queries Replace raw sqlite queries with Drizzle ORM. Remove the connect helper and use the shared db instance and schema definitions. Convert getItself, getAll and addStudents to async functions, use eq and lt helpers, and simplify promotion handling. This improves type safety, maintainability, and allows non‑blocking database access. --- routes/(apps)/mobility/api/insert_mobility.ts | 174 ++++++------------ routes/(apps)/students/api/students.ts | 173 ++++++++--------- 2 files changed, 133 insertions(+), 214 deletions(-) diff --git a/routes/(apps)/mobility/api/insert_mobility.ts b/routes/(apps)/mobility/api/insert_mobility.ts index 90f228f..0452d26 100644 --- a/routes/(apps)/mobility/api/insert_mobility.ts +++ b/routes/(apps)/mobility/api/insert_mobility.ts @@ -1,55 +1,36 @@ import { Handlers } from "$fresh/server.ts"; -import { Database } from "@db/sqlite"; +import { db } from "$root/databases/db.ts"; +import { mobility, promotions, students } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm"; 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 studentRows = await db + .select({ + id: students.userId, + firstName: students.firstName, + lastName: students.lastName, + promotionId: students.promotionId, + endyear: promotions.endyear, + current: promotions.current, + }) + .from(students) + .leftJoin(promotions, eq(students.promotionId, promotions.id)); - 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 mobilityRows = await db.select().from(mobility); - 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(); + const promotionRows = await db + .select({ id: promotions.id, endyear: promotions.endyear, current: promotions.current }) + .from(promotions); return new Response( - JSON.stringify({ mobilities, students, promotions }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, + JSON.stringify({ + mobilities: mobilityRows, + students: studentRows, + promotions: promotionRows, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, ); } catch (error) { console.error("Error fetching mobility data:", error); @@ -58,8 +39,6 @@ export const handler: Handlers = { }, async POST(request) { - console.log("API /mobility/api/insert_mobility POST called"); - try { const body = await request.json(); const { data } = body; @@ -67,32 +46,8 @@ export const handler: Handlers = { 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) { + for (const entry of data) { const { id, studentId, @@ -102,19 +57,16 @@ export const handler: Handlers = { destinationCountry, destinationName, mobilityStatus = "N/A", - } = mobility; + } = entry; - console.log("Processing mobility data:", mobility); + const studentExists = await db + .select({ userId: students.userId }) + .from(students) + .where(eq(students.userId, studentId)) + .limit(1) + .then((rows) => rows.length > 0); - 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) { + if (!studentExists) { console.warn(`Skipping mobility for unknown studentId: ${studentId}`); continue; } @@ -123,43 +75,39 @@ export const handler: Handlers = { if (startDate && endDate) { const start = new Date(startDate); const end = new Date(endDate); - if (start <= end) { - calculatedWeeksCount = Math.ceil( + calculatedWeeksCount = start <= end + ? Math.ceil( (end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000), - ); - } else { - calculatedWeeksCount = null; - } + ) + : 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, - ); + await db + .insert(mobility) + .values({ + id, + studentId, + startDate, + endDate, + weeksCount: calculatedWeeksCount, + destinationCountry, + destinationName, + mobilityStatus, + }) + .onConflictDoUpdate({ + target: mobility.id, + set: { + startDate, + endDate, + weeksCount: calculatedWeeksCount, + destinationCountry, + destinationName, + mobilityStatus, + }, + }); } - connection.close(); - console.log("Mobility data inserted/updated successfully."); - return new Response("Data inserted/updated successfully", { - status: 200, - }); + 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)/students/api/students.ts b/routes/(apps)/students/api/students.ts index 157299f..b71e8e6 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -1,150 +1,121 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; -import connect from "$root/databases/connect.ts"; +import { db } from "$root/databases/db.ts"; +import { promotions, students } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { Database } from "@db/sqlite"; +import { eq, lt } from "npm:drizzle-orm"; -/** - * Gets itself from the database. - * @param database The database connection - * @param userId The user ID. - * @returns Itself from the database. - */ -function getItself( - database: Database, +async function getItself( userId: string, -): { student: Student | null; promo: Promotion | null } { - const studentQuery = "select * from students where userId = ?"; - const student: Student | undefined = database.prepare(studentQuery).get( - userId, - ); +): Promise<{ student: Student | null; promo: Promotion | null }> { + const student = await db + .select() + .from(students) + .where(eq(students.userId, userId)) + .limit(1) + .then((rows) => rows[0] ?? null); if (!student) { return { student: null, promo: null }; } - const promoQuery = "select * from promotions where id = ?"; - const promo: Promotion | undefined = database.prepare(promoQuery).get( - student.promotionId, - ); + const promo = await db + .select() + .from(promotions) + .where(eq(promotions.id, student.promotionId!)) + .limit(1) + .then((rows) => rows[0] ?? null); - return { student, promo: promo ?? null }; + return { student, promo }; } -/** - * 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(); +async function getAll(): Promise< + { students: Student[]; promos: Promotion[] } +> { + const rows = await db + .select({ + userId: students.userId, + firstName: students.firstName, + lastName: students.lastName, + mail: students.mail, + promotionId: students.promotionId, + }) + .from(students) + .innerJoin(promotions, eq(students.promotionId, promotions.id)) + .where(lt(promotions.current, 6)); - const promosQuery = "select * from promotions where promotions.current < 6"; - const promos: Promotion[] | undefined = database.prepare(promosQuery).all(); + const promos = await db + .select() + .from(promotions) + .where(lt(promotions.current, 6)); - return { students, promos }; + return { students: rows as Student[], 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, - ); +async function addStudents( + studentList: Student[], + promoId: number, +): Promise { + for (const student of studentList) { + await db + .insert(students) + .values({ + userId: student.userId, + firstName: student.firstName, + lastName: student.lastName, + mail: student.mail, + promotionId: promoId, + }) + .onConflictDoNothing(); } } 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 async GET( _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", - }, - }, + JSON.stringify(await getItself(context.state.session.uid)), + { headers: { "content-type": "application/json" } }, ); } return new Response( - JSON.stringify(getAll(database)), - { - headers: { - "content-type": "application/json", - }, - }, + JSON.stringify(await getAll()), + { 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. - */ + async POST( request: Request, _context: FreshContext, ): Promise { - const { students, promo }: { students: Student[]; promo: string } = - await request.json(); + const { students: studentList, promo }: { + students: Student[]; + promo: string; + } = await request.json(); - if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(students)) { + if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(studentList)) { return new Response(null, { status: 400 }); } - using connection = connect("students"); - const database = connection.database; - const { endyear, current } = promo.match( /^(?\d{4})-(?\d)A$/, )?.groups!; - database.prepare( - "insert or ignore into promotions (endyear, current) values (?, ?)", - ).run(endyear, current); + await db + .insert(promotions) + .values({ endyear: Number(endyear), current: Number(current) }) + .onConflictDoNothing(); - const { id: promoId }: { id: string } = database - .prepare("select id from promotions where endyear = ? and current = ?") - .get(endyear, current)!; + const promo_row = await db + .select() + .from(promotions) + .where(eq(promotions.endyear, Number(endyear))) + .then((rows) => rows.find((r) => r.current === Number(current))!); - addStudents(database, students, promoId); + await addStudents(studentList, promo_row.id); return new Response(null, { status: 201 }); }, -- 2.52.0 From 0f7282ba872005703e82012fc8e25058c4d8a2ae Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 3 Apr 2026 10:50:53 +0200 Subject: [PATCH 006/103] chore(compose.yml): update Docker Compose for production deployment Add postgres service with environment variable for password. Change app image to registry and adjust ports. Update volume mount to production path. Add deploy constraints for manager nodes. --- compose.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/compose.yml b/compose.yml index 530640a..570a02f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,10 +1,26 @@ services: app: - container_name: deno_fresh_app - build: . + image: registry.docker.polytech.djalim.fr/polympr:latest ports: - - "80:80" - - "443:443" + - "8008:80" + - "4430:443" volumes: - - .:/app + - /home/kevin/PolyMPR/:/app command: deno run -A main.ts + deploy: + replicas: 1 + placement: + constraints: [node.role == manager] + + db: + image: postgres + restart: always + shm_size: 128mb + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASS} + deploy: + replicas: 1 + placement: + constraints: [node.role == manager] + + -- 2.52.0 From 612c41c09968eac3c013ccd516a412e1acb616eb Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:22:09 +0200 Subject: [PATCH 007/103] ci: add test job to lint workflow and update deno.json Add test script to deno.json Add @std/assert, @std/testing, happy-dom dependencies --- .gitea/workflows/lint.yml | 3 +++ deno.json | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 6cbfc6d..5194ae0 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -24,3 +24,6 @@ jobs: - name: Check linting run: deno lint + + - name: Run tests + run: deno test -A --no-check tests/ diff --git a/deno.json b/deno.json index c7f729b..1c0cfb3 100644 --- a/deno.json +++ b/deno.json @@ -9,7 +9,8 @@ "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/" }, "lint": { "rules": { @@ -35,6 +36,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)/" }, -- 2.52.0 From 332286c085fb031661954e85fd04dc578a0200f3 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:23:21 +0200 Subject: [PATCH 008/103] test: add API mock, fixtures, and DOM helpers for tests --- tests/helpers/api_mock.ts | 56 ++++++++++++++++++++ tests/helpers/fixtures.ts | 107 ++++++++++++++++++++++++++++++++++++++ tests/helpers/render.ts | 55 ++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 tests/helpers/api_mock.ts create mode 100644 tests/helpers/fixtures.ts create mode 100644 tests/helpers/render.ts diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts new file mode 100644 index 0000000..686f32d --- /dev/null +++ b/tests/helpers/api_mock.ts @@ -0,0 +1,56 @@ +// Mock de fetch() pour les tests + +// deno-lint-ignore no-explicit-any +let _originalFetch: ((input: any, init?: any) => Promise) | null = + null; + +/** + * Remplace globalThis.fetch par un mock qui retourne des réponses + * pré-configurées selon l'URL. + * + * @param routes - Map URL pattern → données de réponse (sera sérialisé en JSON) + */ +export function mockFetch( + routes: Record, +): void { + _originalFetch = globalThis.fetch; + + globalThis.fetch = ( + input: string | URL | Request, + _init?: RequestInit, + ): Promise => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + for (const [pattern, data] of Object.entries(routes)) { + if (url.includes(pattern)) { + return Promise.resolve( + new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + } + + return Promise.resolve( + 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; + } +} diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..e63920e --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,107 @@ +// Types et données de test pour l'API PolyMPR + +export interface Student { + numEtud: number; + nom: string; + prenom: string; + idPromo: number; +} + +export interface Promotion { + idPromo: number; + annee: string; +} + +export interface Prof { + id: number; + nom: string; + prenom: string; +} + +export interface Module { + id: number; + nom: string; +} + +export interface Note { + note: number; + numEtud: number; + idModule: number; +} + +export interface UE { + id: number; + nom: string; +} + +export interface UeModule { + idModule: number; + idUE: number; + idPromo: number; + coeff: number; +} + +export interface Enseignement { + idProf: number; + idModule: number; + idPromo: number; +} + +export interface Ajustement { + numEtud: number; + idUE: number; + valeur: number; +} + +// --- Fixtures --- + +export const students: Student[] = [ + { numEtud: 1, nom: "Dupont", prenom: "Alice", idPromo: 1 }, + { numEtud: 2, nom: "Martin", prenom: "Bob", idPromo: 1 }, + { numEtud: 3, nom: "Durand", prenom: "Claire", idPromo: 2 }, +]; + +export const promotions: Promotion[] = [ + { idPromo: 1, annee: "2025-2026" }, + { idPromo: 2, annee: "2024-2025" }, +]; + +export const profs: Prof[] = [ + { id: 1, nom: "Leclerc", prenom: "Jean" }, + { id: 2, nom: "Moreau", prenom: "Sophie" }, +]; + +export const modules: Module[] = [ + { id: 1, nom: "Mathématiques" }, + { id: 2, nom: "Informatique" }, + { id: 3, nom: "Physique" }, +]; + +export const notes: Note[] = [ + { note: 15, numEtud: 1, idModule: 1 }, + { note: 12, numEtud: 1, idModule: 2 }, + { note: 18, numEtud: 2, idModule: 1 }, + { note: 9, numEtud: 3, idModule: 3 }, +]; + +export const ues: UE[] = [ + { id: 1, nom: "Sciences fondamentales" }, + { id: 2, nom: "Sciences appliquées" }, +]; + +export const ueModules: UeModule[] = [ + { idModule: 1, idUE: 1, idPromo: 1, coeff: 3 }, + { idModule: 2, idUE: 2, idPromo: 1, coeff: 4 }, + { idModule: 3, idUE: 1, idPromo: 2, coeff: 2 }, +]; + +export const enseignements: Enseignement[] = [ + { idProf: 1, idModule: 1, idPromo: 1 }, + { idProf: 2, idModule: 2, idPromo: 1 }, + { idProf: 1, idModule: 3, idPromo: 2 }, +]; + +export const ajustements: Ajustement[] = [ + { numEtud: 1, idUE: 1, valeur: 0.5 }, + { numEtud: 3, idUE: 1, valeur: -1 }, +]; 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; + } +} -- 2.52.0 From 01fd6e9984b5a83ef4cd07d44b62659b8963197d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:24:02 +0200 Subject: [PATCH 009/103] test: add e2e, integration, and unit tests for fixtures and mockFetch --- tests/e2e/.gitkeep | 0 tests/integration/.gitkeep | 0 tests/unit/example_test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 tests/e2e/.gitkeep create mode 100644 tests/integration/.gitkeep create mode 100644 tests/unit/example_test.ts diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/example_test.ts b/tests/unit/example_test.ts new file mode 100644 index 0000000..1de45e0 --- /dev/null +++ b/tests/unit/example_test.ts @@ -0,0 +1,61 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { notes, students } from "../helpers/fixtures.ts"; +import { cleanupDOM, setupDOM } from "../helpers/render.ts"; + +Deno.test("fixtures - students have expected shape", () => { + assertEquals(students.length, 3); + assertEquals(students[0].nom, "Dupont"); + assertExists(students[0].numEtud); +}); + +Deno.test("mockFetch - returns mocked data for matching route", async () => { + mockFetch({ + "/students": students, + "/notes": notes, + }); + + 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(); + } +}); + +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(); + } + }, +}); -- 2.52.0 From 17c5b33a5ba70b715de4560f7ee53b2df732021f Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:31:45 +0200 Subject: [PATCH 010/103] refactor(test): improve fetch mock and update fixture types Add support for HTTP methods, status codes, body and headers in the fetch mock. Track calls and expose getFetchCalls for assertions. Update fixture interfaces to use string IDs, add ImportResult and ApiError types, and provide standard error constants. Adjust fixture data to match new types. --- tests/helpers/api_mock.ts | 105 ++++++++++++++++++++++++++++++-------- tests/helpers/fixtures.ts | 92 ++++++++++++++++++++++----------- 2 files changed, 145 insertions(+), 52 deletions(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index 686f32d..e7fb68a 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -1,47 +1,94 @@ -// Mock de fetch() pour les tests +// 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 qui retourne des réponses - * pré-configurées selon l'URL. + * Remplace globalThis.fetch par un mock configurable. * - * @param routes - Map URL pattern → données de réponse (sera sérialisé en JSON) + * 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, + routes: Record, ): void { _originalFetch = globalThis.fetch; + _calls = []; - globalThis.fetch = ( + globalThis.fetch = async ( input: string | URL | Request, - _init?: RequestInit, + init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const method = (init?.method ?? "GET").toUpperCase(); - for (const [pattern, data] of Object.entries(routes)) { - if (url.includes(pattern)) { - return Promise.resolve( - new Response(JSON.stringify(data), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + // 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; } } - return Promise.resolve( - new Response(JSON.stringify({ error: "Not Found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }), - ); + _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" }, + }); }; } @@ -53,4 +100,20 @@ export function restoreFetch(): void { 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/fixtures.ts b/tests/helpers/fixtures.ts index e63920e..67ece22 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -1,14 +1,16 @@ -// Types et données de test pour l'API PolyMPR +// Types et données de test alignés sur l'API REST PolyMPR + +// --- Types --- export interface Student { numEtud: number; nom: string; prenom: string; - idPromo: number; + idPromo: string; } export interface Promotion { - idPromo: number; + idPromo: string; annee: string; } @@ -19,14 +21,14 @@ export interface Prof { } export interface Module { - id: number; + id: string; nom: string; } export interface Note { note: number; numEtud: number; - idModule: number; + idModule: string; } export interface UE { @@ -35,16 +37,16 @@ export interface UE { } export interface UeModule { - idModule: number; + idModule: string; idUE: number; - idPromo: number; + idPromo: string; coeff: number; } export interface Enseignement { idProf: number; - idModule: number; - idPromo: number; + idModule: string; + idPromo: string; } export interface Ajustement { @@ -53,17 +55,37 @@ export interface Ajustement { valeur: number; } +export interface ImportResult { + imported: number; + errors: { line: number; message: string }[]; +} + +export interface ApiError { + error: string; +} + // --- Fixtures --- export const students: Student[] = [ - { numEtud: 1, nom: "Dupont", prenom: "Alice", idPromo: 1 }, - { numEtud: 2, nom: "Martin", prenom: "Bob", idPromo: 1 }, - { numEtud: 3, nom: "Durand", prenom: "Claire", idPromo: 2 }, + { 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: 1, annee: "2025-2026" }, - { idPromo: 2, annee: "2024-2025" }, + { idPromo: "4AFISE25/26", annee: "2025" }, + { idPromo: "3AFISE25/26", annee: "2025" }, + { idPromo: "JIA4A2526", annee: "2025" }, ]; export const profs: Prof[] = [ @@ -72,36 +94,44 @@ export const profs: Prof[] = [ ]; export const modules: Module[] = [ - { id: 1, nom: "Mathématiques" }, - { id: 2, nom: "Informatique" }, - { id: 3, nom: "Physique" }, + { id: "JIN702C", nom: "Optimisation" }, + { id: "JIN703C", nom: "Informatique" }, + { id: "JIN704C", nom: "Physique" }, ]; export const notes: Note[] = [ - { note: 15, numEtud: 1, idModule: 1 }, - { note: 12, numEtud: 1, idModule: 2 }, - { note: 18, numEtud: 2, idModule: 1 }, - { note: 9, numEtud: 3, idModule: 3 }, + { 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: "Sciences fondamentales" }, - { id: 2, nom: "Sciences appliquées" }, + { id: 1, nom: "UE Informatique" }, + { id: 2, nom: "UE Mathématiques" }, ]; export const ueModules: UeModule[] = [ - { idModule: 1, idUE: 1, idPromo: 1, coeff: 3 }, - { idModule: 2, idUE: 2, idPromo: 1, coeff: 4 }, - { idModule: 3, idUE: 1, idPromo: 2, coeff: 2 }, + { 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: 1, idPromo: 1 }, - { idProf: 2, idModule: 2, idPromo: 1 }, - { idProf: 1, idModule: 3, idPromo: 2 }, + { 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: 1, idUE: 1, valeur: 0.5 }, - { numEtud: 3, idUE: 1, valeur: -1 }, + { 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" }; -- 2.52.0 From 50afe2ae66375f17214b0235ca2da7097ae37744 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:49:30 +0200 Subject: [PATCH 011/103] test: add mock DB helper for unit tests test: add tests for fixtures, mock fetch, mock db, and happy-dom - Add comprehensive fixture shape tests. - Expand mockFetch to support methods, status codes, and body tracking. - Introduce getFetchCalls to inspect intercepted requests. - Add mockDb helper for in-memory DB operations. - Reorganize tests for clarity and coverage. - Ensure happy-dom setup/cleanup works correctly. --- tests/helpers/db_mock.ts | 122 ++++++++++++++++++ tests/unit/example_test.ts | 253 +++++++++++++++++++++++++++++++++---- 2 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 tests/helpers/db_mock.ts 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/unit/example_test.ts b/tests/unit/example_test.ts index 1de45e0..b0dc313 100644 --- a/tests/unit/example_test.ts +++ b/tests/unit/example_test.ts @@ -1,24 +1,46 @@ import { assertEquals, assertExists } from "@std/assert"; -import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; -import { notes, students } from "../helpers/fixtures.ts"; +import { + getFetchCalls, + mockFetch, + restoreFetch, +} from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { + type Student, + ERROR_CONFLICT, + ERROR_NOT_FOUND, + modules, + notes, + students, +} from "../helpers/fixtures.ts"; import { cleanupDOM, setupDOM } from "../helpers/render.ts"; -Deno.test("fixtures - students have expected shape", () => { +// --- Fixtures --- + +Deno.test("fixtures - students match API shape", () => { assertEquals(students.length, 3); - assertEquals(students[0].nom, "Dupont"); - assertExists(students[0].numEtud); + assertEquals(students[0].numEtud, 21212006); + assertEquals(students[0].idPromo, "4AFISE25/26"); + assertEquals(typeof students[0].idPromo, "string"); }); -Deno.test("mockFetch - returns mocked data for matching route", async () => { - mockFetch({ - "/students": students, - "/notes": notes, - }); +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"); @@ -29,7 +51,6 @@ Deno.test("mockFetch - returns mocked data for matching route", async () => { Deno.test("mockFetch - returns 404 for unknown routes", async () => { mockFetch({}); - try { const res = await fetch("http://localhost/api/unknown"); assertEquals(res.status, 404); @@ -38,24 +59,208 @@ Deno.test("mockFetch - returns 404 for unknown routes", async () => { } }); +// --- 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(); + setupDOM(); + try { + const doc = globalThis.document; + assertExists(doc); - try { - const doc = globalThis.document; - assertExists(doc); + const div = doc.createElement("div"); + div.textContent = "hello"; + doc.body.appendChild(div); - const div = doc.createElement("div"); - div.textContent = "hello"; - doc.body.appendChild(div); - - assertEquals(doc.body.textContent, "hello"); - } finally { - cleanupDOM(); - } + assertEquals(doc.body.textContent, "hello"); + } finally { + cleanupDOM(); + } }, }); -- 2.52.0 From 4211df32a8d090c5c456a4c5cf7ca26c3b0dd6ac Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 12:02:55 +0200 Subject: [PATCH 012/103] style: format api mock return type and test imports/JSON body --- tests/helpers/api_mock.ts | 6 +++++- tests/unit/example_test.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index e7fb68a..26db188 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -106,7 +106,11 @@ export function restoreFetch(): void { /** * Retourne la liste des appels fetch interceptés. */ -export function getFetchCalls(): { url: string; method: string; body?: unknown }[] { +export function getFetchCalls(): { + url: string; + method: string; + body?: unknown; +}[] { return [..._calls]; } diff --git a/tests/unit/example_test.ts b/tests/unit/example_test.ts index b0dc313..86618ec 100644 --- a/tests/unit/example_test.ts +++ b/tests/unit/example_test.ts @@ -1,16 +1,12 @@ import { assertEquals, assertExists } from "@std/assert"; -import { - getFetchCalls, - mockFetch, - restoreFetch, -} from "../helpers/api_mock.ts"; +import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; import { createMockDb } from "../helpers/db_mock.ts"; import { - type Student, ERROR_CONFLICT, ERROR_NOT_FOUND, modules, notes, + type Student, students, } from "../helpers/fixtures.ts"; import { cleanupDOM, setupDOM } from "../helpers/render.ts"; @@ -116,7 +112,11 @@ Deno.test("mockFetch - 409 conflict", async () => { try { const res = await fetch("http://localhost/api/enseignements", { method: "POST", - body: JSON.stringify({ idProf: 1, idModule: "JIN702C", idPromo: "4AFISE25/26" }), + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), }); assertEquals(res.status, 409); } finally { -- 2.52.0 From ce5acacca63d4007dc6957ef750de4bbe90292ab Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 12:04:10 +0200 Subject: [PATCH 013/103] refactor(api_mock.ts): remove async from mockFetch to match signature --- tests/helpers/api_mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index 26db188..0f8af47 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -29,7 +29,7 @@ export function mockFetch( _originalFetch = globalThis.fetch; _calls = []; - globalThis.fetch = async ( + globalThis.fetch = ( input: string | URL | Request, init?: RequestInit, ): Promise => { -- 2.52.0 From 32ffbb7cda4619ae1761078712d4646544904362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 12:50:46 +0200 Subject: [PATCH 014/103] PMPR-32 : GET /ues - liste toutes les UEs --- databases/schema.ts | 92 +++++++++++++++++++++++++++++----- routes/(apps)/notes/api/ues.ts | 19 +++++++ 2 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 routes/(apps)/notes/api/ues.ts diff --git a/databases/schema.ts b/databases/schema.ts index 1d7632e..d9fcc1a 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,32 +1,100 @@ import { - date, + doublePrecision, integer, pgTable, + primaryKey, serial, text, } from "npm:drizzle-orm/pg-core"; +// Ancien schéma conservé export const promotions = pgTable("promotions", { - id: serial("id").primaryKey(), - endyear: integer("endyear"), - current: integer("current"), + id: text("idPromo").primaryKey(), + annee: text("annee"), }); export const students = pgTable("students", { - userId: text("userId").primaryKey(), - firstName: text("firstName"), - lastName: text("lastName"), - mail: text("mail"), - promotionId: integer("promotionId").references(() => promotions.id), + numEtud: serial("numEtud").primaryKey(), + nom: text("nom"), + prenom: text("prenom"), + idPromo: text("idPromo").references(() => promotions.id), }); export const mobility = pgTable("mobility", { id: serial("id").primaryKey(), - studentId: text("studentId").references(() => students.userId), - startDate: date("startDate"), - endDate: date("endDate"), + studentId: text("studentId").references(() => students.numEtud), + startDate: text("startDate"), + endDate: text("endDate"), weeksCount: integer("weeksCount"), destinationCountry: text("destinationCountry"), destinationName: text("destinationName"), mobilityStatus: text("mobilityStatus").default("N/A"), }); + +// Nouveau schéma +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + nom: text("nom"), +}); + +export const permissions = pgTable("permissions", { + id: serial("id").primaryKey(), + nom: text("nom"), +}); + +export const rolePermissions = pgTable("role_permissions", { + idRole: integer("idRole").references(() => roles.id), + idPermission: integer("idPermission").references(() => permissions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idRole, t.idPermission] }), +})); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + nom: text("nom"), + prenom: text("prenom"), + idRole: integer("idRole").references(() => roles.id), +}); + +export const modules = pgTable("modules", { + id: text("id").primaryKey(), + nom: text("nom"), +}); + +export const enseignements = pgTable("enseignements", { + idProf: text("idProf").references(() => users.id), + idModule: text("idModule").references(() => modules.id), + idPromo: text("idPromo").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").references(() => modules.id), + idUE: integer("idUE").references(() => ues.id), + idPromo: text("idPromo").references(() => promotions.id), + coeff: doublePrecision("coeff"), +}, (t) => ({ + pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }), +})); + +export const notes = pgTable("notes", { + numEtud: integer("numEtud").references(() => students.numEtud), + idModule: text("idModule").references(() => modules.id), + note: doublePrecision("note"), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idModule] }), +})); + +export const ajustements = pgTable("ajustements", { + numEtud: integer("numEtud").references(() => students.numEtud), + idUE: integer("idUE").references(() => ues.id), + valeur: doublePrecision("valeur"), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idUE] }), +})); \ No newline at end of file diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts new file mode 100644 index 0000000..562ca90 --- /dev/null +++ b/routes/(apps)/notes/api/ues.ts @@ -0,0 +1,19 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { ues } from "../../../../databases/schema.ts"; + +export const handler: Handlers = { + 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 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From b8d359a507db394e12d4f101484f51f0ff27e0d7 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:13:33 +0200 Subject: [PATCH 015/103] feat(database): add roles, permissions, users, modules, and related tables Add tables for role-based access control and academic entities. Includes modules, UEs, notes, and adjustments. Update students and mobility tables to reference new primary keys. This enables richer data modeling for the application. --- databases/schema.ts | 107 ++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/databases/schema.ts b/databases/schema.ts index d9fcc1a..a74b3f1 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,4 +1,5 @@ import { + date, doublePrecision, integer, pgTable, @@ -7,7 +8,30 @@ import { text, } from "npm:drizzle-orm/pg-core"; -// Ancien schéma conservé +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"), @@ -15,56 +39,20 @@ export const promotions = pgTable("promotions", { export const students = pgTable("students", { numEtud: serial("numEtud").primaryKey(), - nom: text("nom"), - prenom: text("prenom"), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), idPromo: text("idPromo").references(() => promotions.id), }); -export const mobility = pgTable("mobility", { - id: serial("id").primaryKey(), - studentId: text("studentId").references(() => students.numEtud), - startDate: text("startDate"), - endDate: text("endDate"), - weeksCount: integer("weeksCount"), - destinationCountry: text("destinationCountry"), - destinationName: text("destinationName"), - mobilityStatus: text("mobilityStatus").default("N/A"), -}); - -// Nouveau schéma -export const roles = pgTable("roles", { - id: serial("id").primaryKey(), - nom: text("nom"), -}); - -export const permissions = pgTable("permissions", { - id: serial("id").primaryKey(), - nom: text("nom"), -}); - -export const rolePermissions = pgTable("role_permissions", { - idRole: integer("idRole").references(() => roles.id), - idPermission: integer("idPermission").references(() => permissions.id), -}, (t) => ({ - pk: primaryKey({ columns: [t.idRole, t.idPermission] }), -})); - -export const users = pgTable("users", { - id: text("id").primaryKey(), - nom: text("nom"), - prenom: text("prenom"), - idRole: integer("idRole").references(() => roles.id), -}); - export const modules = pgTable("modules", { id: text("id").primaryKey(), - nom: text("nom"), + nom: text("nom").notNull(), }); export const enseignements = pgTable("enseignements", { - idProf: text("idProf").references(() => users.id), - idModule: text("idModule").references(() => modules.id), - idPromo: text("idPromo").references(() => promotions.id), + 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] }), })); @@ -75,26 +63,37 @@ export const ues = pgTable("ues", { }); export const ueModules = pgTable("ue_modules", { - idModule: text("idModule").references(() => modules.id), - idUE: integer("idUE").references(() => ues.id), - idPromo: text("idPromo").references(() => promotions.id), - coeff: doublePrecision("coeff"), + 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").references(() => students.numEtud), - idModule: text("idModule").references(() => modules.id), - note: doublePrecision("note"), + 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").references(() => students.numEtud), - idUE: integer("idUE").references(() => ues.id), - valeur: doublePrecision("valeur"), + 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] }), -})); \ No newline at end of file +})); + +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"), +}); -- 2.52.0 From 9168ca53da9fe8f7094bf3ff4b0bde82a39eb818 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:30:19 +0200 Subject: [PATCH 016/103] feat(admin): scaffold admin module and add GET /permissions endpoint Co-Authored-By: Claude Sonnet 4.6 --- routes/(apps)/admin/(_props)/props.ts | 13 +++++++++++++ routes/(apps)/admin/api/example.ts | 22 ++++++++++++++++++++++ routes/(apps)/admin/api/permissions.ts | 22 ++++++++++++++++++++++ routes/(apps)/admin/index.tsx | 2 ++ routes/(apps)/admin/partials/index.tsx | 13 +++++++++++++ 5 files changed, 72 insertions(+) create mode 100644 routes/(apps)/admin/(_props)/props.ts create mode 100644 routes/(apps)/admin/api/example.ts create mode 100644 routes/(apps)/admin/api/permissions.ts create mode 100644 routes/(apps)/admin/index.tsx create mode 100644 routes/(apps)/admin/partials/index.tsx diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts new file mode 100644 index 0000000..3ae55a1 --- /dev/null +++ b/routes/(apps)/admin/(_props)/props.ts @@ -0,0 +1,13 @@ +import { AppProperties } from "$root/defaults/interfaces.ts"; + +const properties: AppProperties = { + name: "Admin", + icon: "school", + pages: { + index: "Homepage", + }, + adminOnly: [], + hint: "PolyMPR module", +}; + +export default properties; 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/permissions.ts b/routes/(apps)/admin/api/permissions.ts new file mode 100644 index 0000000..1175eb0 --- /dev/null +++ b/routes/(apps)/admin/api/permissions.ts @@ -0,0 +1,22 @@ +import { Handlers } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; + +const PERMISSIONS = [ + { 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" }, +] as const; + +export const handler: Handlers = { + GET(_request, _context): Response { + return new Response(JSON.stringify(PERMISSIONS), { + headers: { "content-type": "application/json" }, + }); + }, +}; 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/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx new file mode 100644 index 0000000..12adb8d --- /dev/null +++ b/routes/(apps)/admin/partials/index.tsx @@ -0,0 +1,13 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/routes/_middleware.ts"; + +export async function Index(request: Request, context: FreshContext) { + return

Welcome to Admin.

; +} + +export const config = getPartialsConfig(); +export default makePartials(Index); -- 2.52.0 From 03b58e7b0a960945bfd493068679fe325f3d7593 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:24:03 +0200 Subject: [PATCH 017/103] feat(admin/api/users): add GET and POST endpoints for users --- routes/(apps)/admin/api/users.ts | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 routes/(apps)/admin/api/users.ts diff --git a/routes/(apps)/admin/api/users.ts b/routes/(apps)/admin/api/users.ts new file mode 100644 index 0000000..931795a --- /dev/null +++ b/routes/(apps)/admin/api/users.ts @@ -0,0 +1,60 @@ +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"; + +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 { + const body: { id: string; nom: string; prenom: string; idRole: number } = + await request.json(); + + if (!body.id || !body.nom || !body.prenom) { + 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" }, + }); + }, +}; -- 2.52.0 From 5a86f69093cf8cdedd081eaaae0815788c64f099 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:25:29 +0200 Subject: [PATCH 018/103] feat: add CRUD endpoints for users by id --- routes/(apps)/admin/api/users/[id].ts | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 routes/(apps)/admin/api/users/[id].ts diff --git a/routes/(apps)/admin/api/users/[id].ts b/routes/(apps)/admin/api/users/[id].ts new file mode 100644 index 0000000..81c5871 --- /dev/null +++ b/routes/(apps)/admin/api/users/[id].ts @@ -0,0 +1,66 @@ +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"; + +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} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const [deleted] = await db + .delete(users) + .where(eq(users.id, context.params.id)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; -- 2.52.0 From 9a3f49ecfebd9f965d02a84e093f6116086cf05f Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:26:55 +0200 Subject: [PATCH 019/103] feat(admin/api): add roles endpoint with GET and POST --- routes/(apps)/admin/api/roles.ts | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 routes/(apps)/admin/api/roles.ts diff --git a/routes/(apps)/admin/api/roles.ts b/routes/(apps)/admin/api/roles.ts new file mode 100644 index 0000000..59688c8 --- /dev/null +++ b/routes/(apps)/admin/api/roles.ts @@ -0,0 +1,64 @@ +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"; + +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" } }, + ); + }, +}; -- 2.52.0 From b5f134d0160897e54131864ed453827591366c73 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:28:11 +0200 Subject: [PATCH 020/103] feat(roles): add CRUD endpoints for role by id --- routes/(apps)/admin/api/roles/[idRole].ts | 97 +++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 routes/(apps)/admin/api/roles/[idRole].ts diff --git a/routes/(apps)/admin/api/roles/[idRole].ts b/routes/(apps)/admin/api/roles/[idRole].ts new file mode 100644 index 0000000..60bf2cc --- /dev/null +++ b/routes/(apps)/admin/api/roles/[idRole].ts @@ -0,0 +1,97 @@ +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"; + +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} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const id = Number(context.params.idRole); + + // Cascade delete role_permissions first + await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); + + const [deleted] = await db + .delete(roles) + .where(eq(roles.id, id)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; -- 2.52.0 From 0d45bd4c1c8aba7c5d57b39b4a50eb265a56e563 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:52:19 +0200 Subject: [PATCH 021/103] refactor(students): simplify API, remove unused imports and helpers refactor(students): add query param filtering, enforce employee role for POST refactor(students): return created student in POST response --- routes/(apps)/students/api/students.ts | 135 +++++++------------------ 1 file changed, 35 insertions(+), 100 deletions(-) diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index b71e8e6..2c09757 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -1,122 +1,57 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { promotions, students } from "$root/databases/schema.ts"; +import { students } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { eq, lt } from "npm:drizzle-orm"; - -async function getItself( - userId: string, -): Promise<{ student: Student | null; promo: Promotion | null }> { - const student = await db - .select() - .from(students) - .where(eq(students.userId, userId)) - .limit(1) - .then((rows) => rows[0] ?? null); - - if (!student) { - return { student: null, promo: null }; - } - - const promo = await db - .select() - .from(promotions) - .where(eq(promotions.id, student.promotionId!)) - .limit(1) - .then((rows) => rows[0] ?? null); - - return { student, promo }; -} - -async function getAll(): Promise< - { students: Student[]; promos: Promotion[] } -> { - const rows = await db - .select({ - userId: students.userId, - firstName: students.firstName, - lastName: students.lastName, - mail: students.mail, - promotionId: students.promotionId, - }) - .from(students) - .innerJoin(promotions, eq(students.promotionId, promotions.id)) - .where(lt(promotions.current, 6)); - - const promos = await db - .select() - .from(promotions) - .where(lt(promotions.current, 6)); - - return { students: rows as Student[], promos }; -} - -async function addStudents( - studentList: Student[], - promoId: number, -): Promise { - for (const student of studentList) { - await db - .insert(students) - .values({ - userId: student.userId, - firstName: student.firstName, - lastName: student.lastName, - mail: student.mail, - promotionId: promoId, - }) - .onConflictDoNothing(); - } -} +import { eq } from "npm:drizzle-orm"; export const handler: Handlers = { + // #7 GET /students async GET( - _request: Request, + request: Request, context: FreshContext, ): Promise { - if (context.state.session.eduPersonPrimaryAffiliation == "student") { - return new Response( - JSON.stringify(await getItself(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(await getAll()), - { 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" }, + }); }, + // #8 POST /students async POST( request: Request, - _context: FreshContext, + context: FreshContext, ): Promise { - const { students: studentList, 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(studentList)) { + const body: { numEtud: number; nom: string; prenom: string; idPromo: string } = + await request.json(); + + if (!body.nom || !body.prenom || !body.idPromo) { return new Response(null, { status: 400 }); } - const { endyear, current } = promo.match( - /^(?\d{4})-(?\d)A$/, - )?.groups!; + const [created] = await db + .insert(students) + .values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .returning(); - await db - .insert(promotions) - .values({ endyear: Number(endyear), current: Number(current) }) - .onConflictDoNothing(); - - const promo_row = await db - .select() - .from(promotions) - .where(eq(promotions.endyear, Number(endyear))) - .then((rows) => rows.find((r) => r.current === Number(current))!); - - await addStudents(studentList, promo_row.id); - - return new Response(null, { status: 201 }); + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); }, }; -- 2.52.0 From f959cf0d3a1a71f4b580972ad6d5de57b5663a42 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:57:10 +0200 Subject: [PATCH 022/103] feat(students): add CSV import endpoint for student data --- .../students/api/students/import-csv.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 routes/(apps)/students/api/students/import-csv.ts 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" }, + }); + }, +}; -- 2.52.0 From 4eaea48ebd6d602cca6ba474e020bac360bb96ac Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 13:58:59 +0200 Subject: [PATCH 023/103] feat(students): add CRUD endpoints for student by numEtud --- .../(apps)/students/api/students/[numEtud].ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 routes/(apps)/students/api/students/[numEtud].ts diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts new file mode 100644 index 0000000..bf0e64f --- /dev/null +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -0,0 +1,83 @@ +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"; +import { eq } from "npm:drizzle-orm"; + +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 [updated] = await db + .update(students) + .set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .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} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const numEtud = Number(context.params.numEtud); + const [deleted] = await db + .delete(students) + .where(eq(students.numEtud, numEtud)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; -- 2.52.0 From 3f0c8d079f149ff09b86549c5b025462219b9681 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:00:05 +0200 Subject: [PATCH 024/103] feat(students): add promotions API for employees --- routes/(apps)/students/api/promotions.ts | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 routes/(apps)/students/api/promotions.ts diff --git a/routes/(apps)/students/api/promotions.ts b/routes/(apps)/students/api/promotions.ts new file mode 100644 index 0000000..83b318c --- /dev/null +++ b/routes/(apps)/students/api/promotions.ts @@ -0,0 +1,50 @@ +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"; +import { eq } from "npm:drizzle-orm"; + +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" }, + }); + }, +}; -- 2.52.0 From b2847a4a7dd2a3705782c9ac00985c27dd62b934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 14:16:31 +0200 Subject: [PATCH 025/103] =?UTF-8?q?PMPR-42=20:=20GET=20/notes=20-=20r?= =?UTF-8?q?=C3=A9cup=C3=A8re=20les=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/(apps)/notes/api/notes.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 routes/(apps)/notes/api/notes.ts diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts new file mode 100644 index 0000000..385caaa --- /dev/null +++ b/routes/(apps)/notes/api/notes.ts @@ -0,0 +1,39 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { notes } from "../../../../databases/schema.ts"; +import { eq } from "npm:drizzle-orm"; + +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 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From 2f15efe21e9c6bb0e264b2ef2f30011123e0c016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 14:28:03 +0200 Subject: [PATCH 026/103] =?UTF-8?q?PMPR-33=20:=20POST=20/ues=20-=20cr?= =?UTF-8?q?=C3=A9er=20une=20UE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/(apps)/notes/api/ues.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 562ca90..19b7d51 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -3,6 +3,7 @@ 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); @@ -16,4 +17,26 @@ export const handler: Handlers = { 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) { + 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 }); + } + }, }; \ No newline at end of file -- 2.52.0 From 2c1fd7e5ade4c123ffb2d1ed2b3578b5dd8c17f1 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:01:32 +0200 Subject: [PATCH 027/103] feat(promotions): add CRUD endpoints for promotion by id - GET /promotions/{idPromo} returns promotion or 404 - PUT /promotions/{idPromo} updates year or 404 - DELETE /promotions/{idPromo} deletes promotion or 404 - Only employees allowed, otherwise 403 --- .../students/api/promotions/[idPromo].ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 routes/(apps)/students/api/promotions/[idPromo].ts diff --git a/routes/(apps)/students/api/promotions/[idPromo].ts b/routes/(apps)/students/api/promotions/[idPromo].ts new file mode 100644 index 0000000..25d71f3 --- /dev/null +++ b/routes/(apps)/students/api/promotions/[idPromo].ts @@ -0,0 +1,79 @@ +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"; +import { eq } from "npm:drizzle-orm"; + +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} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const [deleted] = await db + .delete(promotions) + .where(eq(promotions.id, context.params.idPromo)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; -- 2.52.0 From 6c18189d9f9d1034db362bdd7d681fd5b9d17be9 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:23:34 +0200 Subject: [PATCH 028/103] chore(deps): update drizzle-orm to 0.45.2 and pg to 8.20.0 --- databases/db.ts | 4 ++-- databases/schema.ts | 2 +- routes/(apps)/admin/api/roles.ts | 8 ++++++-- routes/(apps)/admin/api/roles/[idRole].ts | 8 ++++++-- routes/(apps)/admin/api/users.ts | 13 ++++++++++--- routes/(apps)/admin/api/users/[id].ts | 6 +++--- routes/(apps)/admin/partials/index.tsx | 2 +- routes/(apps)/mobility/api/insert_mobility.ts | 12 +++++++++--- routes/(apps)/students/api/promotions.ts | 1 - routes/(apps)/students/api/promotions/[idPromo].ts | 2 +- routes/(apps)/students/api/students.ts | 10 +++++++--- routes/(apps)/students/api/students/[numEtud].ts | 6 +++--- 12 files changed, 49 insertions(+), 25 deletions(-) diff --git a/databases/db.ts b/databases/db.ts index b405c39..05326f9 100644 --- a/databases/db.ts +++ b/databases/db.ts @@ -1,5 +1,5 @@ -import { drizzle } from "npm:drizzle-orm/node-postgres"; -import pg from "npm:pg"; +import { drizzle } from "npm:drizzle-orm@0.45.2/node-postgres"; +import pg from "npm:pg@8.20.0"; const { Pool } = pg; diff --git a/databases/schema.ts b/databases/schema.ts index a74b3f1..823c7a2 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -6,7 +6,7 @@ import { primaryKey, serial, text, -} from "npm:drizzle-orm/pg-core"; +} from "npm:drizzle-orm@0.45.2/pg-core"; export const roles = pgTable("roles", { id: serial("id").primaryKey(), diff --git a/routes/(apps)/admin/api/roles.ts b/routes/(apps)/admin/api/roles.ts index 59688c8..15b328a 100644 --- a/routes/(apps)/admin/api/roles.ts +++ b/routes/(apps)/admin/api/roles.ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; async function getRoleWithPermissions( id: number, @@ -20,7 +20,11 @@ async function getRoleWithPermissions( .from(rolePermissions) .where(eq(rolePermissions.idRole, id)); - return { id: role.id, nom: role.nom, permissions: perms.map((p) => p.idPermission) }; + return { + id: role.id, + nom: role.nom, + permissions: perms.map((p) => p.idPermission), + }; } export const handler: Handlers = { diff --git a/routes/(apps)/admin/api/roles/[idRole].ts b/routes/(apps)/admin/api/roles/[idRole].ts index 60bf2cc..d29d047 100644 --- a/routes/(apps)/admin/api/roles/[idRole].ts +++ b/routes/(apps)/admin/api/roles/[idRole].ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), @@ -25,7 +25,11 @@ async function getRoleWithPermissions( .from(rolePermissions) .where(eq(rolePermissions.idRole, id)); - return { id: role.id, nom: role.nom, permissions: perms.map((p) => p.idPermission) }; + return { + id: role.id, + nom: role.nom, + permissions: perms.map((p) => p.idPermission), + }; } export const handler: Handlers = { diff --git a/routes/(apps)/admin/api/users.ts b/routes/(apps)/admin/api/users.ts index 931795a..d2fbd56 100644 --- a/routes/(apps)/admin/api/users.ts +++ b/routes/(apps)/admin/api/users.ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #60 GET /users @@ -42,14 +42,21 @@ export const handler: Handlers = { if (existing) { return new Response( - JSON.stringify({ error: "Un utilisateur avec cet identifiant existe déjà" }), + 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 }) + .values({ + id: body.id, + nom: body.nom, + prenom: body.prenom, + idRole: body.idRole, + }) .returning(); return new Response(JSON.stringify(created), { diff --git a/routes/(apps)/admin/api/users/[id].ts b/routes/(apps)/admin/api/users/[id].ts index 81c5871..236156c 100644 --- a/routes/(apps)/admin/api/users/[id].ts +++ b/routes/(apps)/admin/api/users/[id].ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), @@ -33,8 +33,8 @@ export const handler: Handlers = { request: Request, context: FreshContext, ): Promise { - const body: { nom: string; prenom: string; idRole: number } = - await request.json(); + const body: { nom: string; prenom: string; idRole: number } = await request + .json(); const [updated] = await db .update(users) diff --git a/routes/(apps)/admin/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx index 12adb8d..4e0c915 100644 --- a/routes/(apps)/admin/partials/index.tsx +++ b/routes/(apps)/admin/partials/index.tsx @@ -5,7 +5,7 @@ import { import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/routes/_middleware.ts"; -export async function Index(request: Request, context: FreshContext) { +export function Index(_request: Request, _context: FreshContext) { return

Welcome to Admin.

; } diff --git a/routes/(apps)/mobility/api/insert_mobility.ts b/routes/(apps)/mobility/api/insert_mobility.ts index 0452d26..a6e9aa9 100644 --- a/routes/(apps)/mobility/api/insert_mobility.ts +++ b/routes/(apps)/mobility/api/insert_mobility.ts @@ -1,7 +1,7 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { mobility, promotions, students } from "$root/databases/schema.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { async GET() { @@ -21,7 +21,11 @@ export const handler: Handlers = { const mobilityRows = await db.select().from(mobility); const promotionRows = await db - .select({ id: promotions.id, endyear: promotions.endyear, current: promotions.current }) + .select({ + id: promotions.id, + endyear: promotions.endyear, + current: promotions.current, + }) .from(promotions); return new Response( @@ -107,7 +111,9 @@ export const handler: Handlers = { }); } - return new Response("Data inserted/updated successfully", { status: 200 }); + 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)/students/api/promotions.ts b/routes/(apps)/students/api/promotions.ts index 83b318c..8e87820 100644 --- a/routes/(apps)/students/api/promotions.ts +++ b/routes/(apps)/students/api/promotions.ts @@ -2,7 +2,6 @@ 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"; -import { eq } from "npm:drizzle-orm"; export const handler: Handlers = { // #13 GET /promotions diff --git a/routes/(apps)/students/api/promotions/[idPromo].ts b/routes/(apps)/students/api/promotions/[idPromo].ts index 25d71f3..a206d3a 100644 --- a/routes/(apps)/students/api/promotions/[idPromo].ts +++ b/routes/(apps)/students/api/promotions/[idPromo].ts @@ -2,7 +2,7 @@ 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"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index 2c09757..65ed62d 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -2,7 +2,7 @@ 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"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #7 GET /students @@ -37,8 +37,12 @@ export const handler: Handlers = { return new Response(null, { status: 403 }); } - const body: { numEtud: number; nom: string; prenom: string; idPromo: string } = - await request.json(); + const body: { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; + } = await request.json(); if (!body.nom || !body.prenom || !body.idPromo) { return new Response(null, { status: 400 }); diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts index bf0e64f..3d92371 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -2,7 +2,7 @@ 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"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), @@ -45,8 +45,8 @@ export const handler: Handlers = { } const numEtud = Number(context.params.numEtud); - const body: { nom: string; prenom: string; idPromo: string } = - await request.json(); + const body: { nom: string; prenom: string; idPromo: string } = await request + .json(); const [updated] = await db .update(students) -- 2.52.0 From 522945316911027de6ede746295f0cf8645e3ec6 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:38:20 +0200 Subject: [PATCH 029/103] chore(drizzle.config.ts): import process for env variable support --- drizzle.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/drizzle.config.ts b/drizzle.config.ts index 22b4b99..9cacf5e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "drizzle-kit"; +import process from "node:process"; export default defineConfig({ dialect: "postgresql", -- 2.52.0 From cf3c7c069328afe0db0ac5c54fd7c13ddbfe089c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:02:17 +0200 Subject: [PATCH 030/103] feat(admin/api): add modules endpoint with GET and POST handlers --- routes/(apps)/admin/api/modules.ts | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 routes/(apps)/admin/api/modules.ts diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts new file mode 100644 index 0000000..582e215 --- /dev/null +++ b/routes/(apps)/admin/api/modules.ts @@ -0,0 +1,63 @@ +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"; + +export const handler: Handlers = { + // #23 GET /modules + 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(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 }); + } + + const body: { id: string; nom: string } = await request.json(); + + if (!body.id || !body.nom) { + 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 module 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" }, + }); + }, +}; -- 2.52.0 From 92182b952f8ce4fdee49f9e2c1b965b848706d55 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 22 Apr 2026 14:03:22 +0200 Subject: [PATCH 031/103] feat(modules): add CRUD endpoints for module resource Implement GET, PUT, DELETE for /modules/{idModule} with 404 handling. --- routes/(apps)/admin/api/modules/[idModule].ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 routes/(apps)/admin/api/modules/[idModule].ts diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts new file mode 100644 index 0000000..3062772 --- /dev/null +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -0,0 +1,65 @@ +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"; + +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 { + const body: { nom: string } = await request.json(); + + 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} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + const [deleted] = await db + .delete(modules) + .where(eq(modules.id, context.params.idModule)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; -- 2.52.0 From f3c1f10999499d1a4efdc3df062b76b4098bb575 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 15:05:40 +0200 Subject: [PATCH 032/103] feat(api): implement enseignements CRUD endpoints Add CRUD API for enseignements (prof-module-promo associations): - POST /enseignements: Create with validation (201/409) - GET /enseignements/{idProf}/{idModule}/{idPromo}: Read by composite key (200/404) - DELETE /enseignements/{idProf}/{idModule}/{idPromo}: Delete by composite key (204/404) Access control: Employee-only (403 Forbidden) Tests: 7 unit tests added Note: RBAC implementation pending (current access control is temporary) --- routes/(apps)/admin/api/enseignements.ts | 70 +++++++ .../[idProf]_[idModule]_[idPromo].ts | 75 +++++++ tests/unit/enseignements_test.ts | 197 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 routes/(apps)/admin/api/enseignements.ts create mode 100644 routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts create mode 100644 tests/unit/enseignements_test.ts diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts new file mode 100644 index 0000000..0f6c09d --- /dev/null +++ b/routes/(apps)/admin/api/enseignements.ts @@ -0,0 +1,70 @@ +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 = { + // #29 POST /enseignements + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const body: { + idProf: string; + idModule: string; + idPromo: string; + } = await request.json(); + + 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..30dbd8a --- /dev/null +++ b/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts @@ -0,0 +1,75 @@ +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/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts new file mode 100644 index 0000000..17bff52 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,197 @@ +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, + enseignements, + type Enseignement, +} from "../helpers/fixtures.ts"; + +Deno.test("enseignements - POST 201 creates new enseignement", async () => { + const newEnseignement: Enseignement = { + idProf: 1, + idModule: "JIN705C", + idPromo: "4AFISE25/26", + }; + + mockFetch({ + "/enseignements": { + method: "POST", + status: 201, + body: newEnseignement, + }, + }); + + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newEnseignement), + }); + assertEquals(res.status, 201); + const data = await res.json(); + assertEquals(data.idProf, 1); + assertEquals(data.idModule, "JIN705C"); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } +}); + +Deno.test("enseignements - POST 409 conflict on duplicate", async () => { + mockFetch({ + "/enseignements": { + method: "POST", + status: 409, + body: ERROR_CONFLICT, + }, + }); + + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 409); + const data = await res.json(); + assertEquals(data.error, "Ressource déjà existante"); + } finally { + restoreFetch(); + } +}); + +Deno.test( + "enseignements - GET 200 returns enseignement by composite key", + async () => { + const enseignement = enseignements[0]; + const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; + + mockFetch({ + [path]: { + status: 200, + body: enseignement, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", + ); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.idProf, 1); + assertEquals(data.idModule, "JIN702C"); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } + }, +); + +Deno.test("enseignements - GET 404 when enseignement not found", async () => { + mockFetch({ + "/enseignements/999/JIN999/UNKNOWN": { + status: 404, + body: ERROR_NOT_FOUND, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/999/JIN999/UNKNOWN", + ); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } +}); + +Deno.test("enseignements - DELETE 204 removes enseignement", async () => { + const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; + + mockFetch({ + [path]: { + method: "DELETE", + status: 204, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 204); + assertEquals(res.body, null); + } finally { + restoreFetch(); + } +}); + +Deno.test( + "enseignements - DELETE 404 when enseignement not found", + async () => { + mockFetch({ + "/enseignements/999/JIN999/UNKNOWN": { + method: "DELETE", + status: 404, + body: ERROR_NOT_FOUND, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/999/JIN999/UNKNOWN", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } + }, +); + +Deno.test("enseignements - mockDb operations", () => { + const db = createMockDb({ tables: { enseignements: [...enseignements] } }); + + // Test findOne + const found = db.findOne( + "enseignements", + (r) => + r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", + ); + assertExists(found); + assertEquals(found.idProf, 1); + + // Test insert + const newEnseignement: Enseignement = { + idProf: 3, + idModule: "JIN705C", + idPromo: "4AFISE25/26", + }; + db.insert("enseignements", newEnseignement); + assertEquals(db.getTable("enseignements").length, 4); + + // Test deleteWhere + const deleted = db.deleteWhere( + "enseignements", + (r) => + r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", + ); + assertEquals(deleted, 1); + assertEquals(db.getTable("enseignements").length, 3); +}); -- 2.52.0 From 2739a01ab5f4fd1aa6a621a05888b2973dd99757 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 15:34:09 +0200 Subject: [PATCH 033/103] fix(api): align enseignements route with Fresh file routing - Replace flat file `[idProf]_[idModule]_[idPromo].ts` with nested structure `[idProf]/[idModule]/[idPromo].ts` - Ensures URL matches `/enseignements/{idProf}/{idModule}/{idPromo}` --- .../[idModule]/[idPromo].ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename routes/(apps)/admin/api/enseignements/{[idProf]_[idModule]_[idPromo].ts => [idProf]/[idModule]/[idPromo].ts} (100%) diff --git a/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts similarity index 100% rename from routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts rename to routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts -- 2.52.0 From a19a1e6c1318d02b96b6017634ac6da4c086c156 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 16:59:52 +0200 Subject: [PATCH 034/103] test(api): remove enseignements unit tests Unit tests removed as they only used mocks without real value. --- tests/unit/enseignements_test.ts | 197 ------------------------------- 1 file changed, 197 deletions(-) delete mode 100644 tests/unit/enseignements_test.ts diff --git a/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts deleted file mode 100644 index 17bff52..0000000 --- a/tests/unit/enseignements_test.ts +++ /dev/null @@ -1,197 +0,0 @@ -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, - enseignements, - type Enseignement, -} from "../helpers/fixtures.ts"; - -Deno.test("enseignements - POST 201 creates new enseignement", async () => { - const newEnseignement: Enseignement = { - idProf: 1, - idModule: "JIN705C", - idPromo: "4AFISE25/26", - }; - - mockFetch({ - "/enseignements": { - method: "POST", - status: 201, - body: newEnseignement, - }, - }); - - try { - const res = await fetch("http://localhost/api/enseignements", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEnseignement), - }); - assertEquals(res.status, 201); - const data = await res.json(); - assertEquals(data.idProf, 1); - assertEquals(data.idModule, "JIN705C"); - assertEquals(data.idPromo, "4AFISE25/26"); - } finally { - restoreFetch(); - } -}); - -Deno.test("enseignements - POST 409 conflict on duplicate", async () => { - mockFetch({ - "/enseignements": { - method: "POST", - status: 409, - body: ERROR_CONFLICT, - }, - }); - - try { - const res = await fetch("http://localhost/api/enseignements", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - idProf: 1, - idModule: "JIN702C", - idPromo: "4AFISE25/26", - }), - }); - assertEquals(res.status, 409); - const data = await res.json(); - assertEquals(data.error, "Ressource déjà existante"); - } finally { - restoreFetch(); - } -}); - -Deno.test( - "enseignements - GET 200 returns enseignement by composite key", - async () => { - const enseignement = enseignements[0]; - const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; - - mockFetch({ - [path]: { - status: 200, - body: enseignement, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", - ); - assertEquals(res.status, 200); - const data = await res.json(); - assertEquals(data.idProf, 1); - assertEquals(data.idModule, "JIN702C"); - assertEquals(data.idPromo, "4AFISE25/26"); - } finally { - restoreFetch(); - } - }, -); - -Deno.test("enseignements - GET 404 when enseignement not found", async () => { - mockFetch({ - "/enseignements/999/JIN999/UNKNOWN": { - status: 404, - body: ERROR_NOT_FOUND, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/999/JIN999/UNKNOWN", - ); - assertEquals(res.status, 404); - const data = await res.json(); - assertEquals(data.error, "Ressource introuvable"); - } finally { - restoreFetch(); - } -}); - -Deno.test("enseignements - DELETE 204 removes enseignement", async () => { - const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; - - mockFetch({ - [path]: { - method: "DELETE", - status: 204, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", - { - method: "DELETE", - }, - ); - assertEquals(res.status, 204); - assertEquals(res.body, null); - } finally { - restoreFetch(); - } -}); - -Deno.test( - "enseignements - DELETE 404 when enseignement not found", - async () => { - mockFetch({ - "/enseignements/999/JIN999/UNKNOWN": { - method: "DELETE", - status: 404, - body: ERROR_NOT_FOUND, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/999/JIN999/UNKNOWN", - { - method: "DELETE", - }, - ); - assertEquals(res.status, 404); - const data = await res.json(); - assertEquals(data.error, "Ressource introuvable"); - } finally { - restoreFetch(); - } - }, -); - -Deno.test("enseignements - mockDb operations", () => { - const db = createMockDb({ tables: { enseignements: [...enseignements] } }); - - // Test findOne - const found = db.findOne( - "enseignements", - (r) => - r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", - ); - assertExists(found); - assertEquals(found.idProf, 1); - - // Test insert - const newEnseignement: Enseignement = { - idProf: 3, - idModule: "JIN705C", - idPromo: "4AFISE25/26", - }; - db.insert("enseignements", newEnseignement); - assertEquals(db.getTable("enseignements").length, 4); - - // Test deleteWhere - const deleted = db.deleteWhere( - "enseignements", - (r) => - r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", - ); - assertEquals(deleted, 1); - assertEquals(db.getTable("enseignements").length, 3); -}); -- 2.52.0 From 96b7edf77f606cc0f95cc00ffcc68cca682d5798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 18:01:35 +0200 Subject: [PATCH 035/103] =?UTF-8?q?PMPR-43=20:=20POST=20/notes=20-=20cr?= =?UTF-8?q?=C3=A9er=20une=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/(apps)/notes/api/notes.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index 385caaa..0dcdf39 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -36,4 +36,26 @@ export const handler: Handlers = { 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 } = body; + + if (note === undefined || !numEtud || !idModule) { + return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { status: 400 }); + } + + const result = await db.insert(notes).values({ note, numEtud, idModule }).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 }); + } + }, }; \ No newline at end of file -- 2.52.0 From bbc9ea58e2886076cc2f11c32adc69853b5a603e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 18:08:19 +0200 Subject: [PATCH 036/103] PMPR-37 : GET /ue-modules - liste les associations UE-Module --- routes/(apps)/notes/api/ue-modules.ts | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 routes/(apps)/notes/api/ue-modules.ts diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts new file mode 100644 index 0000000..17ae928 --- /dev/null +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -0,0 +1,36 @@ +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"; + +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 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From 33d023986c34dac92371baa46e489e095110af09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 18:26:45 +0200 Subject: [PATCH 037/103] =?UTF-8?q?PMPR-34=20:=20GET=20/ues/{idUE}=20-=20r?= =?UTF-8?q?=C3=A9cup=C3=A9rer=20une=20UE=20par=20son=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/(apps)/notes/api/ues/[idUE].ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 routes/(apps)/notes/api/ues/[idUE].ts diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/notes/api/ues/[idUE].ts new file mode 100644 index 0000000..9fb70fa --- /dev/null +++ b/routes/(apps)/notes/api/ues/[idUE].ts @@ -0,0 +1,37 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../../databases/db.ts"; +import { ues } from "../../../../../databases/schema.ts"; +import { eq } from "npm:drizzle-orm"; + +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 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From 022994e5a76d9d62c09f8d352ec11807d8c69c10 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 18:28:22 +0200 Subject: [PATCH 038/103] feat(api): implement ajustements list and create endpoints - GET /ajustements: list all ajustements with optional numEtud/idUE filters - POST /ajustements: create new ajustement for student in UE - Both require employee role --- routes/(apps)/notes/api/ajustements.ts | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 routes/(apps)/notes/api/ajustements.ts diff --git a/routes/(apps)/notes/api/ajustements.ts b/routes/(apps)/notes/api/ajustements.ts new file mode 100644 index 0000000..6239fb2 --- /dev/null +++ b/routes/(apps)/notes/api/ajustements.ts @@ -0,0 +1,83 @@ +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 } = + 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" } }, + ); + } + + const [created] = await db + .insert(ajustements) + .values({ + numEtud: body.numEtud, + idUE: body.idUE, + valeur: body.valeur, + }) + .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 }); + } + }, +}; -- 2.52.0 From d3f1f433e1f77c5103087b8fedae1bbaf55b0090 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 18:27:49 +0200 Subject: [PATCH 039/103] feat(api): implement single ajustement retrieval endpoint - GET /ajustements/{numEtud}/{idUE}: get ajustement by student numEtud and UE id - Requires employee role --- .../notes/api/ajustements/[numEtud]/[idUE].ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts 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..ae7232c --- /dev/null +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -0,0 +1,43 @@ +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"; + +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(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" }, + }); + }, +}; -- 2.52.0 From 79669d60cf41e649fd244494128771f1284ae3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 18:11:53 +0200 Subject: [PATCH 040/103] =?UTF-8?q?PMPR-38=20:=20POST=20/ue-modules=20-=20?= =?UTF-8?q?associer=20un=20module=20=C3=A0=20une=20UE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/(apps)/notes/api/ue-modules.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index 17ae928..ba56b66 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -33,4 +33,26 @@ export const handler: Handlers = { 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 }); + } + + 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-module:", error); + return new Response("Failed to create UE-module", { status: 500 }); + } + }, }; \ No newline at end of file -- 2.52.0 From 7ad70c45255eb8fd1b638b89d6acd046f034a954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Thu, 23 Apr 2026 11:21:16 +0200 Subject: [PATCH 041/103] =?UTF-8?q?GET=20/notes/{numEtud}/{idModule}=20-?= =?UTF-8?q?=20r=C3=A9cup=C3=A9rer=20le=20d=C3=A9tail=20d'une=20note=20pour?= =?UTF-8?q?=20un=20=C3=A9tudiant=20dans=20un=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notes/api/notes/[numEtud]/[idModule].ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts 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..18092f6 --- /dev/null +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -0,0 +1,43 @@ +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"; + +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 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From eeb087ea76146f1552cd512539f3de9c5e23110b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Wed, 22 Apr 2026 18:39:34 +0200 Subject: [PATCH 042/103] PMPR-36 : DELETE /ues/{idUE} - supprimer une UE --- routes/(apps)/notes/api/ues/[idUE].ts | 68 ++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/notes/api/ues/[idUE].ts index 9fb70fa..c92e118 100644 --- a/routes/(apps)/notes/api/ues/[idUE].ts +++ b/routes/(apps)/notes/api/ues/[idUE].ts @@ -34,4 +34,70 @@ export const handler: Handlers = { return new Response("Failed to fetch data", { status: 500 }); } }, -}; \ No newline at end of file + + // #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 + 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 result = await db.delete(ues).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(null, { status: 204 }); + } catch (error) { + console.error("Error deleting UE:", error); + return new Response("Failed to delete UE", { status: 500 }); + } + }, +}; -- 2.52.0 From 49876339bfd387c2b676174f28482ea2eeca0314 Mon Sep 17 00:00:00 2001 From: Anys Date: Thu, 23 Apr 2026 10:52:50 +0200 Subject: [PATCH 043/103] feat(api): implement ajustement update endpoint - PUT /ajustements/{numEtud}/{idUE}: update ajustement valeur - Requires employee role --- .../notes/api/ajustements/[numEtud]/[idUE].ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index ae7232c..ed21ac4 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -40,4 +40,42 @@ export const handler: Handlers = { 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 } = await request.json(); + + if (body.valeur === undefined) { + return new Response(JSON.stringify({ error: "Champ requis: valeur" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const [updated] = await db + .update(ajustements) + .set({ valeur: body.valeur }) + .where(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" }, + }); + }, }; -- 2.52.0 From 22750ba07e3ec11563facebe7cbf78229613e13a Mon Sep 17 00:00:00 2001 From: Anys Date: Thu, 23 Apr 2026 10:53:39 +0200 Subject: [PATCH 044/103] feat(api): implement ajustement delete endpoint - DELETE /ajustements/{numEtud}/{idUE}: remove ajustement from DB - Requires employee role - Returns 204 on success --- .../notes/api/ajustements/[numEtud]/[idUE].ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index ed21ac4..c9b3ab0 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -78,4 +78,30 @@ export const handler: Handlers = { 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(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, }; -- 2.52.0 From 457b008ba38d4e06e07ca00491d2f0e8669d51f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Thu, 23 Apr 2026 11:56:26 +0200 Subject: [PATCH 045/103] PMPR-46/47 : PUT et DELETE /notes/{numEtud}/{idModule} --- .../notes/api/notes/[numEtud]/[idModule].ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts index 18092f6..24d8a28 100644 --- a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -40,4 +40,82 @@ export const handler: Handlers = { 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 } = body; + + if (note === undefined) { + return new Response("Champ 'note' manquant", { status: 400 }); + } + + const result = await db.update(notes).set({ note }).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 }); + } + }, }; \ No newline at end of file -- 2.52.0 From 9976b9e2b4ed192308343072405a93de574397ac Mon Sep 17 00:00:00 2001 From: Anys Date: Thu, 23 Apr 2026 13:03:23 +0200 Subject: [PATCH 046/103] feat(api): implement UE-Module association get endpoint - GET /ue-modules/{idModule}/{idUE}/{idPromo}: recover the detail of an ue-module association by its composite key - requires employee role --- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts new file mode 100644 index 0000000..6a8ea5f --- /dev/null +++ b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -0,0 +1,53 @@ +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 { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = new Response( + JSON.stringify({ error: "Association UE-Module 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" }, + }); + }, +}; -- 2.52.0 From 66183c2ad811408785ad6b455de95389313318c0 Mon Sep 17 00:00:00 2001 From: Anys Date: Thu, 23 Apr 2026 12:28:30 +0200 Subject: [PATCH 047/103] feat(api): implement UE-Module coefficient update and deletion endpoint - PUT /ue-modules/{idModule}/{idUE}/{idPromo}: update coeff for UE-Module-Promo association - DELETE /ue-modules/{idModule}/{idUE}/{idPromo}: remove UE-Module-Promo association - requires employee role --- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index 6a8ea5f..676e05b 100644 --- a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -2,7 +2,7 @@ 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 { eq } from "npm:drizzle-orm@0.45.2"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Association UE-Module introuvable" }), @@ -50,4 +50,87 @@ export const handler: Handlers = { 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 }); + }, }; -- 2.52.0 From 980efcfbc38d485cafc6c742a9fad6c168d507ce Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Thu, 23 Apr 2026 14:29:08 +0200 Subject: [PATCH 048/103] ci: add Deno code check job and enable lint on develop --- .gitea/workflows/deploy.yml | 17 +++++++++++++++++ .gitea/workflows/lint.yml | 4 ++++ 2 files changed, 21 insertions(+) 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 5194ae0..a6815e4 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 -- 2.52.0 From cdd9c0bf0640fcd7b6621fd42ff88b3cb6d496c3 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:23:12 +0200 Subject: [PATCH 049/103] chore(test): set up integration test framework with postgres - Generate Drizzle migrations (databases/migrations/) - Add databases/schema.kit.ts for drizzle-kit (Node-compatible imports) - Update drizzle.config.ts to use schema.kit.ts - Add deno tasks: test:unit, test:integration, migrate - Add tests/helpers/db_integration.ts: testDb, truncateAll, seed helpers - Add .gitea/workflows/test.yml: CI with postgres service container - Update lint.yml: run test:unit only (no DB needed) - Update deploy.yml: add check-code job, gate deploy on it --- .../migrations/0000_square_jetstream.sql | 100 +++ databases/migrations/meta/0000_snapshot.json | 680 ++++++++++++++++++ databases/migrations/meta/_journal.json | 13 + databases/schema.kit.ts | 99 +++ deno.json | 5 +- drizzle.config.ts | 2 +- tests/helpers/db_integration.ts | 106 +++ 7 files changed, 1003 insertions(+), 2 deletions(-) create mode 100644 databases/migrations/0000_square_jetstream.sql create mode 100644 databases/migrations/meta/0000_snapshot.json create mode 100644 databases/migrations/meta/_journal.json create mode 100644 databases/schema.kit.ts create mode 100644 tests/helpers/db_integration.ts 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/meta/0000_snapshot.json b/databases/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..a99e37c --- /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": {} + } +} \ No newline at end of file diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json new file mode 100644 index 0000000..6834a0b --- /dev/null +++ b/databases/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777155028708, + "tag": "0000_square_jetstream", + "breakpoints": true + } + ] +} \ No newline at end of file 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/deno.json b/deno.json index 1c0cfb3..b3d8c09 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,10 @@ "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 .", - "test": "deno test -A --no-check tests/" + "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/", + "migrate": "node_modules/.bin/drizzle-kit migrate" }, "lint": { "rules": { diff --git a/drizzle.config.ts b/drizzle.config.ts index 9cacf5e..ad9cdc7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,7 +3,7 @@ import process from "node:process"; export default defineConfig({ dialect: "postgresql", - schema: "./databases/schema.ts", + schema: "./databases/schema.kit.ts", out: "./databases/migrations", dbCredentials: { host: process.env.POSTGRES_HOST!, diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts new file mode 100644 index 0000000..b74cd36 --- /dev/null +++ b/tests/helpers/db_integration.ts @@ -0,0 +1,106 @@ +// 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", + }); +} + +export const testPool = createTestPool(); +export const testDb = drizzle(testPool, { schema }); + +// Ordre de truncate respectant les FK (enfants avant parents) +const TRUNCATE_ORDER = [ + "mobility", + "ajustements", + "notes", + "ue_modules", + "enseignements", + "role_permissions", + "students", + "ue_modules", + "users", + "modules", + "ues", + "promotions", + "permissions", + "roles", +] as const; + +/** + * 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 { + // Désactiver les FK temporairement pour simplifier + await client.query("SET session_replication_role = replica"); + for (const table of TRUNCATE_ORDER) { + await client.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE`); + } + await client.query("SET session_replication_role = DEFAULT"); + } 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(); +} -- 2.52.0 From 6db04045f472af38ac9531dd86c2ce1904da7554 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:24:27 +0200 Subject: [PATCH 050/103] fix(lint): add version to drizzle-orm imports and prefix unused NOT_FOUND --- .gitea/workflows/test.yml | 74 ++++ CLAUDE.md | 338 ++++++++++++++++++ bugs.md | 158 ++++++++ compose.yml | 4 +- databases/migrations/meta/0000_snapshot.json | 2 +- databases/migrations/meta/_journal.json | 2 +- flake.lock | 61 ++++ flake.nix | 62 ++++ package.json | 2 +- routes/(apps)/admin/api/enseignements.ts | 2 +- routes/(apps)/admin/api/modules.ts | 2 +- routes/(apps)/admin/api/modules/[idModule].ts | 2 +- routes/(apps)/notes/api/notes.ts | 11 +- .../notes/api/notes/[numEtud]/[idModule].ts | 72 ++-- routes/(apps)/notes/api/ue-modules.ts | 20 +- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 19 +- routes/(apps)/notes/api/ues.ts | 2 +- routes/(apps)/notes/api/ues/[idUE].ts | 73 ++-- shell.nix | 23 ++ toolbox/compile.sh | 33 ++ 20 files changed, 880 insertions(+), 82 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 CLAUDE.md create mode 100644 bugs.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 shell.nix create mode 100755 toolbox/compile.sh diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..9578842 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,74 @@ +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: Run unit tests + run: deno task test:unit + + integration: + name: "Integration tests" + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: polympr_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + 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: Install drizzle-kit + run: npm install --ignore-scripts + + - name: Apply migrations + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task migrate + + - name: Run integration tests + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task test:integration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fe5c70d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,338 @@ +# PolyMPR - Claude Code Context + +## 📋 Project Overview + +**PolyMPR** (Poly Management Platform for Resources) is a modular HR management +system built with **Deno + Fresh** framework. It's designed to help +organizations manage HR, student records, notes, mobility programs, and +role-based administration. + +### Stack + +- **Runtime**: Deno +- **Framework**: Fresh (edge-ready web framework) +- **Database**: PostgreSQL with Drizzle ORM +- **Frontend**: Preact with signals +- **Authentication**: JWT-based via cookies +- **Testing**: Deno test framework with HappyDOM for DOM testing + +### Current Status + +🚧 **In Progress** - Application is far from complete. The schema below is the +**final/definitive schema** that should guide all development. + +--- + +## 🏗️ Architecture + +### Module Structure + +The application uses a **modulith architecture** with the following modules: + +``` +routes/(apps)/ +├── students/ - Student management & promotions +├── notes/ - Grade management & academic records +├── mobility/ - Mobility programs & exchanges +└── admin/ - Role & permission management +``` + +### Key Directories + +- `/routes` - Fresh routes and components +- `/databases` - Database connection, schema, and migrations +- `/defaults` - Interfaces and shared types +- `/tests` - Unit, integration, and E2E tests +- `/static` - Public assets + +### Authentication Flow + +1. User authenticates via CAS (Polytech) +2. JWT token stored in `sessionToken` cookie +3. Middleware validates token on each request +4. Public routes: `/`, `/login`, `/logout`, `/about`, `/contact` +5. All other routes require authentication + +--- + +## 📊 Database Schema (Final/Definitive) + +```mermaid +erDiagram + USER { + string id PK + string nom + string prenom + int idRole FK + } + ROLE { + int id PK + string nom + } + PERMISSION { + int id PK + string nom + } + ROLE_PERMISSION { + int idRole PK,FK + int idPermission PK,FK + } + STUDENT { + int numEtud PK + string nom + string prenom + string idPromo FK + } + PROMOTION { + string idPromo PK + string annee + } + MODULE { + string id PK + string nom + } + ENSEIGNEMENT { + string idProf PK,FK + string idModule PK,FK + string idPromo PK,FK + } + UE { + int id PK + string nom + } + UE_MODULE { + string idModule PK,FK + int idUE PK,FK + string idPromo PK,FK + float coeff + } + NOTE { + int numEtud PK,FK + string idModule PK,FK + float note + } + AJUSTEMENT { + int numEtud PK,FK + int idUE PK,FK + float valeur + } + + USER }o--|| ROLE : "a" + ROLE_PERMISSION }o--|| ROLE : "accorde" + ROLE_PERMISSION }o--|| PERMISSION : "inclut" + ENSEIGNEMENT }o--|| USER : "réalisé par" + ENSEIGNEMENT }o--|| MODULE : "porte sur" + ENSEIGNEMENT }o--|| PROMOTION : "concerne" + STUDENT }o--|| PROMOTION : "appartient à" + UE_MODULE }o--|| MODULE : "associe" + UE_MODULE }o--|| UE : "appartient à" + UE_MODULE }o--|| PROMOTION : "pour" + NOTE }o--|| STUDENT : "reçoit" + NOTE }o--|| MODULE : "dans" + AJUSTEMENT }o--|| STUDENT : "concerne" + AJUSTEMENT }o--|| UE : "dans" +``` + +### Current Schema (Incomplete) + +The current Drizzle ORM schema in `/databases/schema.ts` only implements: + +- `promotions` +- `students` +- `mobility` + +**Migration needed**: Update schema to match the final ER diagram above. + +--- + +## 🎯 Open Issues (69 total) + +### UI Pages + +**Catalog** + +- 📋 UI - Page Catalogue d'applications (#71) + +**Components** + +- 🎨 UI (composant) - Popup Résultats d'import (#75) + +**Students** + +- 📋 UI - Admin – Liste des élèves (#79) +- 📋 UI - Admin – Gestion des promotions (#80) +- 📋 UI - Admin – Import xlsx élèves (#81) +- 📋 UI - Admin – Édition d'un élève (#82) + +**Notes** + +- 📋 UI - Page Élève – Mes Notes (#72) +- 📋 UI - Admin – Consulter les notes (#73) +- 📋 UI - Admin – Importer des notes (.xlsx) (#74) +- 📋 UI - Admin – Édition notes d'un élève (#76) +- 📋 UI - Admin – Récap notes élève / semestre (#77) +- 📋 UI - Admin – Gestion des UEs (#78) + +**Administration** + +- 📋 UI - Gestion des utilisateurs (#83) +- 📋 UI - Gestion des rôles (#84) +- 📋 UI - Permissions d'un rôle (#85) +- 📋 UI - Vue des permissions (#86) +- 📋 UI - Gestion des modules (#87) +- 📋 UI - Enseignements (Assignations) (#88) + +--- + +### API Endpoints + +**Students API** + +- 📋 GET `/students` (#7) +- 📋 POST `/students` (#8) +- 📋 POST `/students/import-csv` (#9) +- 📋 GET `/students/{numEtud}` (#10) +- 📋 PUT `/students/{numEtud}` (#11) +- 📋 DELETE `/students/{numEtud}` (#12) +- 📋 GET `/promotions` (#13) +- 📋 POST `/promotions` (#14) +- 📋 GET `/promotions/{idPromo}` (#15) +- 📋 PUT `/promotions/{idPromo}` (#16) +- 📋 DELETE `/promotions/{idPromo}` (#17) + +**Administration API - Modules & Enseignements** + +- 📋 GET `/modules` (#23) +- 📋 POST `/modules` (#24) +- 📋 GET `/modules/{idModule}` (#25) +- 📋 PUT `/modules/{idModule}` (#26) +- 📋 DELETE `/modules/{idModule}` (#27) +- 📋 POST `/enseignements` (#29) +- 📋 GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) +- 📋 DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) + +**Notes API - UEs & UE-Modules** + +- 📋 GET `/ues` (#32) +- 📋 POST `/ues` (#33) +- 📋 GET `/ues/{idUE}` (#34) +- 📋 PUT `/ues/{idUE}` (#35) +- 📋 DELETE `/ues/{idUE}` (#36) +- 📋 GET `/ue-modules` (#37) +- 📋 POST `/ue-modules` (#38) +- 📋 GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) +- 📋 PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) +- 📋 DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) + +**Notes API - Notes & Ajustements** + +- 📋 GET `/notes` (#42) +- 📋 POST `/notes` (#43) +- 📋 POST `/notes/import-xlsx` (#44) +- 📋 GET `/notes/{numEtud}/{idModule}` (#45) +- 📋 PUT `/notes/{numEtud}/{idModule}` (#46) +- 📋 DELETE `/notes/{numEtud}/{idModule}` (#47) +- 📋 GET `/ajustements` (#48) +- 📋 POST `/ajustements` (#49) +- 📋 GET `/ajustements/{numEtud}/{idUE}` (#50) +- 📋 PUT `/ajustements/{numEtud}/{idUE}` (#51) +- 📋 DELETE `/ajustements/{numEtud}/{idUE}` (#52) + +**Administration API - Users, Roles & Permissions** + +- 📋 GET `/users` (#60) +- 📋 POST `/users` (#61) +- 📋 GET `/users/{id}` (#62) +- 📋 PUT `/users/{id}` (#63) +- 📋 DELETE `/users/{id}` (#64) +- 📋 GET `/roles` (#65) +- 📋 POST `/roles` (#66) +- 📋 GET `/roles/{idRole}` (#67) +- 📋 PUT `/roles/{idRole}` (#68) +- 📋 DELETE `/roles/{idRole}` (#69) +- 📋 GET `/permissions` (#70) + +--- + +## 🎨 Design Reference + +**Figma Prototype**: +https://www.figma.com/design/La79bsUsWnJCtMsrrt2zGd/Prototype?node-id=0-1 + +This is the **final design specification** for the UI. All UI implementations +should follow this design. + +--- + +## 🚀 Development Guidelines + +### Getting Started + +```bash +# Run tests +deno task test + +# Start development server +deno task start + +# Build for production +deno task build + +# Format & lint +deno task check +``` + +### Git Workflow + +1. Create branch: `git checkout -b PMPR-{ISSUE_ID}` +2. Implement changes +3. Run tests and linting +4. Submit PR + +### Code Style + +- Format: Follow Deno defaults (enforced via `deno fmt`) +- Linting: Fresh recommended rules +- TypeScript strict mode enabled +- Use Drizzle ORM for database operations + +### Testing + +- Write unit tests for business logic +- Integration tests for API endpoints +- E2E tests with HappyDOM for UI interactions +- Mock database with provided helpers + +--- + +## 📦 Key Dependencies + +- **fresh@1.7.3** - Web framework +- **drizzle-orm@0.45.2** - ORM +- **pg@8.20.0** - PostgreSQL driver +- **@popov/jwt@1.0.1** - JWT utilities +- **preact@10.22.0** - UI library +- **happy-dom@16.0.0** - DOM testing + +--- + +## 🔗 Related Resources + +- **Repository**: https://git.polytech.djalim.fr/djalim/PolyMPR +- **Issue Tracker**: Gitea (via `tea` CLI) +- **Wiki**: Check CONTRIBUTING.md for dev setup +- **Database**: PostgreSQL (configured in `.env`) + +--- + +## 💡 Important Notes + +1. **Current Limitation**: The database schema in `/databases/schema.ts` does + NOT match the final ER diagram. This is a priority migration task. +2. **Design System**: Follow the Figma prototype for all UI work. +3. **Module Pattern**: Each module should follow the same pattern: routes, API + endpoints, components, and tests. +4. **Permissions**: All admin operations should respect the ROLE_PERMISSION + system. +5. **Fresh Conventions**: Routes use Fresh's file-based routing convention + (e.g., `routes/path/index.tsx`). 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/compose.yml b/compose.yml index 570a02f..f2abf83 100644 --- a/compose.yml +++ b/compose.yml @@ -16,11 +16,9 @@ services: image: postgres restart: always shm_size: 128mb - environment: + environment: POSTGRES_PASSWORD: ${POSTGRES_PASS} deploy: replicas: 1 placement: constraints: [node.role == manager] - - diff --git a/databases/migrations/meta/0000_snapshot.json b/databases/migrations/meta/0000_snapshot.json index a99e37c..819cf78 100644 --- a/databases/migrations/meta/0000_snapshot.json +++ b/databases/migrations/meta/0000_snapshot.json @@ -677,4 +677,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index 6834a0b..ad99452 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} 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/package.json b/package.json index 4cf5711..bbd458d 100644 --- a/package.json +++ b/package.json @@ -9,4 +9,4 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.21.0" } -} \ No newline at end of file +} diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index 0f6c09d..06408bc 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -4,7 +4,7 @@ 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( +const _NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), { status: 404, headers: { "content-type": "application/json" } }, ); diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 582e215..2cb2fe7 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #23 GET /modules diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index 3062772..6f17dfe 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -2,7 +2,7 @@ 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"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index 0dcdf39..22d387e 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -1,7 +1,7 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../databases/db.ts"; import { notes } from "../../../../databases/schema.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #42 GET /notes @@ -44,10 +44,13 @@ export const handler: Handlers = { const { note, numEtud, idModule } = body; if (note === undefined || !numEtud || !idModule) { - return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { status: 400 }); + return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { + status: 400, + }); } - const result = await db.insert(notes).values({ note, numEtud, idModule }).returning(); + const result = await db.insert(notes).values({ note, numEtud, idModule }) + .returning(); return new Response(JSON.stringify(result[0]), { status: 201, @@ -58,4 +61,4 @@ export const handler: Handlers = { return new Response("Failed to create note", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts index 24d8a28..8618366 100644 --- a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -1,20 +1,23 @@ 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"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // #45 GET /notes/:numEtud/:idModule + // #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" }, - }); + 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( @@ -25,10 +28,13 @@ export const handler: Handlers = { ); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -48,10 +54,13 @@ export const handler: Handlers = { const { idModule } = context.params; if (isNaN(numEtud)) { - return new Response(JSON.stringify({ error: "Paramètre numEtud invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const body = await request.json(); @@ -69,10 +78,13 @@ export const handler: Handlers = { ).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({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -92,10 +104,13 @@ export const handler: Handlers = { const { idModule } = context.params; if (isNaN(numEtud)) { - return new Response(JSON.stringify({ error: "Paramètre numEtud invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.delete(notes).where( @@ -106,10 +121,13 @@ export const handler: Handlers = { ).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({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(null, { status: 204 }); @@ -118,4 +136,4 @@ export const handler: Handlers = { return new Response("Failed to delete note", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index ba56b66..8cd48bc 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -1,10 +1,10 @@ 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"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // #37 GET /ue-modules + // #37 GET /ue-modules async GET(request) { try { const url = new URL(request.url); @@ -33,7 +33,7 @@ export const handler: Handlers = { return new Response("Failed to fetch data", { status: 500 }); } }, - + // #38 POST /ue-modules async POST(request) { try { @@ -41,10 +41,18 @@ export const handler: Handlers = { const { idModule, idUE, idPromo, coeff } = body; if (!idModule || !idUE || !idPromo || coeff === undefined) { - return new Response("Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", { status: 400 }); + return new Response( + "Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", + { status: 400 }, + ); } - const result = await db.insert(ueModules).values({ idModule, idUE, idPromo, coeff }).returning(); + const result = await db.insert(ueModules).values({ + idModule, + idUE, + idPromo, + coeff, + }).returning(); return new Response(JSON.stringify(result[0]), { status: 201, @@ -55,4 +63,4 @@ export const handler: Handlers = { return new Response("Failed to create UE-module", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index 676e05b..f447f12 100644 --- a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -91,14 +91,17 @@ export const handler: Handlers = { 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" }, - }); + 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} diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 19b7d51..757245c 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -39,4 +39,4 @@ export const handler: Handlers = { return new Response("Failed to create UE", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/notes/api/ues/[idUE].ts index c92e118..c8f586f 100644 --- a/routes/(apps)/notes/api/ues/[idUE].ts +++ b/routes/(apps)/notes/api/ues/[idUE].ts @@ -1,28 +1,34 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../../databases/db.ts"; import { ues } from "../../../../../databases/schema.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // # 34 GET /ues/:idUE + // # 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" }, - }); + 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({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -41,10 +47,13 @@ export const handler: Handlers = { 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" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const body = await request.json(); @@ -54,13 +63,17 @@ export const handler: Handlers = { return new Response("Champ 'nom' manquant", { status: 400 }); } - const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE)).returning(); + 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({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -79,19 +92,25 @@ export const handler: Handlers = { 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" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.delete(ues).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({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(null, { status: 204 }); 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/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" < Date: Sun, 26 Apr 2026 00:27:07 +0200 Subject: [PATCH 051/103] fix(ci): install npm deps before running unit tests --- .gitea/workflows/test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 9578842..f7dc9ae 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,10 +16,17 @@ jobs: 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: Install dependencies + run: npm install --ignore-scripts + - name: Run unit tests run: deno task test:unit -- 2.52.0 From 60dde4675c279832c535a6024c48b20ccfbe1f08 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:31:15 +0200 Subject: [PATCH 052/103] fix(ci): use deno install for unit tests, add postgres readiness check --- .gitea/workflows/test.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f7dc9ae..c29e9cb 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,16 +16,12 @@ jobs: 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: Install dependencies - run: npm install --ignore-scripts + run: deno install - name: Run unit tests run: deno task test:unit @@ -59,8 +55,15 @@ jobs: with: deno-version: v2.x - - name: Install drizzle-kit - run: npm install --ignore-scripts + - name: Install dependencies + run: npm install --ignore-scripts && deno install + + - name: Wait for postgres + run: | + until pg_isready -h localhost -p 5432 -U test; do + echo "Waiting for postgres..." + sleep 2 + done - name: Apply migrations env: -- 2.52.0 From fdfdd74894c84c86c7137a8d3575af5330c5b240 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:35:42 +0200 Subject: [PATCH 053/103] fix(ci): replace pg_isready with nc for postgres readiness check --- .gitea/workflows/test.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c29e9cb..5715b72 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -38,11 +38,6 @@ jobs: POSTGRES_PASSWORD: test ports: - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - uses: actions/checkout@v4 @@ -60,7 +55,7 @@ jobs: - name: Wait for postgres run: | - until pg_isready -h localhost -p 5432 -U test; do + until nc -z localhost 5432; do echo "Waiting for postgres..." sleep 2 done -- 2.52.0 From c8b808f509c255b05e28cbaa64ed17b48e1a7663 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:37:21 +0200 Subject: [PATCH 054/103] fix(ci): use bash /dev/tcp for postgres readiness check --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5715b72..f9d203f 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -55,7 +55,7 @@ jobs: - name: Wait for postgres run: | - until nc -z localhost 5432; do + until bash -c 'echo > /dev/tcp/localhost/5432' 2>/dev/null; do echo "Waiting for postgres..." sleep 2 done -- 2.52.0 From f42df29f0635dbe6ee22f209e17344517503ef88 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:41:20 +0200 Subject: [PATCH 055/103] fix(ci): use docker run instead of services for postgres --- .gitea/workflows/test.yml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f9d203f..c4179c7 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -29,16 +29,6 @@ jobs: integration: name: "Integration tests" runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_DB: polympr_test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - ports: - - 5432:5432 - steps: - uses: actions/checkout@v4 @@ -50,16 +40,22 @@ jobs: with: deno-version: v2.x - - name: Install dependencies - run: npm install --ignore-scripts && deno install - - - name: Wait for postgres + - name: Start postgres run: | - until bash -c 'echo > /dev/tcp/localhost/5432' 2>/dev/null; do + docker run -d --name postgres \ + -e POSTGRES_DB=polympr_test \ + -e POSTGRES_USER=test \ + -e POSTGRES_PASSWORD=test \ + -p 5432:5432 \ + postgres:16 + until docker exec postgres pg_isready -U test; do echo "Waiting for postgres..." sleep 2 done + - name: Install dependencies + run: npm install --ignore-scripts && deno install + - name: Apply migrations env: POSTGRES_HOST: localhost -- 2.52.0 From d1c3b9375579eebea0643df5f45de20933b02f1b Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:43:11 +0200 Subject: [PATCH 056/103] fix(ci): install postgres via apt-get instead of docker --- .gitea/workflows/test.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c4179c7..fe1fc22 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -42,16 +42,10 @@ jobs: - name: Start postgres run: | - docker run -d --name postgres \ - -e POSTGRES_DB=polympr_test \ - -e POSTGRES_USER=test \ - -e POSTGRES_PASSWORD=test \ - -p 5432:5432 \ - postgres:16 - until docker exec postgres pg_isready -U test; do - echo "Waiting for postgres..." - sleep 2 - done + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null + sudo systemctl start postgresql + sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" + sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" - name: Install dependencies run: npm install --ignore-scripts && deno install -- 2.52.0 From 6b8b5e6aa379f1723b093bbb040a9c49635a2d8c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:46:02 +0200 Subject: [PATCH 057/103] fix(ci): start postgres with pg_ctlcluster instead of systemctl --- .gitea/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fe1fc22..5d11c99 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -43,7 +43,8 @@ jobs: - name: Start postgres run: | sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null - sudo systemctl start postgresql + PG_VER=$(ls /etc/postgresql/) + sudo pg_ctlcluster $PG_VER main start sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" -- 2.52.0 From 91248370da08afaec7189cddde836ae227023f62 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:48:57 +0200 Subject: [PATCH 058/103] fix(ci): add GRANT on public schema and verbose migrate output --- .gitea/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5d11c99..12a1a1c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -47,6 +47,7 @@ jobs: sudo pg_ctlcluster $PG_VER main start 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: Install dependencies run: npm install --ignore-scripts && deno install @@ -58,7 +59,7 @@ jobs: POSTGRES_USER: test POSTGRES_PASS: test POSTGRES_DB: polympr_test - run: deno task migrate + run: node_modules/.bin/drizzle-kit migrate --verbose 2>&1 - name: Run integration tests env: -- 2.52.0 From ce4782580de78627b1e6eda570916dcdb6d5982c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:51:22 +0200 Subject: [PATCH 059/103] fix(ci): remove unsupported --verbose from drizzle-kit migrate --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 12a1a1c..1a9c62b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -59,7 +59,7 @@ jobs: POSTGRES_USER: test POSTGRES_PASS: test POSTGRES_DB: polympr_test - run: node_modules/.bin/drizzle-kit migrate --verbose 2>&1 + run: node_modules/.bin/drizzle-kit migrate - name: Run integration tests env: -- 2.52.0 From 26eedcc4f219c084ddc2829179e3b3042d64ec35 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:54:11 +0200 Subject: [PATCH 060/103] debug(ci): add connection diagnostics before migrate --- .gitea/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a9c62b..c44ff87 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -52,6 +52,15 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts && deno install + - name: Debug connection + run: | + echo "--- pg_hba.conf ---" + sudo cat /etc/postgresql/*/main/pg_hba.conf | grep -v "^#" | grep -v "^$" + echo "--- listening ports ---" + sudo ss -tlnp | grep 5432 || echo "nothing on 5432" + echo "--- test connection ---" + PGPASSWORD=test psql -h localhost -U test -d polympr_test -c "SELECT 1" || echo "connection failed" + - name: Apply migrations env: POSTGRES_HOST: localhost -- 2.52.0 From a95818e3bf95ea1f7793d28fe6a19b361fcaf884 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:57:38 +0200 Subject: [PATCH 061/103] fix(ci): use connection URL with ssl:false in drizzle config --- .gitea/workflows/test.yml | 9 --------- drizzle.config.ts | 10 +++++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c44ff87..1a9c62b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -52,15 +52,6 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts && deno install - - name: Debug connection - run: | - echo "--- pg_hba.conf ---" - sudo cat /etc/postgresql/*/main/pg_hba.conf | grep -v "^#" | grep -v "^$" - echo "--- listening ports ---" - sudo ss -tlnp | grep 5432 || echo "nothing on 5432" - echo "--- test connection ---" - PGPASSWORD=test psql -h localhost -U test -d polympr_test -c "SELECT 1" || echo "connection failed" - - name: Apply migrations env: POSTGRES_HOST: localhost diff --git a/drizzle.config.ts b/drizzle.config.ts index ad9cdc7..27c4a86 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,15 +1,15 @@ 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: { - host: process.env.POSTGRES_HOST!, - port: Number(process.env.POSTGRES_PORT ?? 5432), - user: process.env.POSTGRES_USER!, - password: process.env.POSTGRES_PASS!, - database: process.env.POSTGRES_DB!, + url, + ssl: false, }, }); -- 2.52.0 From daa7f4951fd7e410aa7d812a93ac9a30e8641fcb Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 13:22:45 +0200 Subject: [PATCH 062/103] fix(ci): fix postgres TCP setup and truncateAll superuser error - Use apt-get install + configure listen_addresses + md5 auth in pg_hba so psql can connect via 127.0.0.1 (not just Unix socket) - Use pg_ctlcluster restart after config changes + wait for pg_isready - Replace session_replication_role (requires superuser) with a single TRUNCATE ... CASCADE which handles FK deps without elevated privileges - All 3 integration tests now pass in CI (act + Gitea Actions) Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/test.yml | 21 ++++++------ tests/helpers/db_integration.ts | 29 ++++------------- tests/integration/users_test.ts | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 tests/integration/users_test.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a9c62b..6b3b830 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -44,26 +44,25 @@ jobs: run: | sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null PG_VER=$(ls /etc/postgresql/) - sudo pg_ctlcluster $PG_VER main start + 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 + - name: Install dependencies run: npm install --ignore-scripts && deno install - - name: Apply migrations - env: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - POSTGRES_USER: test - POSTGRES_PASS: test - POSTGRES_DB: polympr_test - run: node_modules/.bin/drizzle-kit migrate - - name: Run integration tests env: - POSTGRES_HOST: localhost + POSTGRES_HOST: 127.0.0.1 POSTGRES_PORT: 5432 POSTGRES_USER: test POSTGRES_PASS: test diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index b74cd36..ee7fe04 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -18,29 +18,15 @@ function createTestPool(): pg.Pool { 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 }); -// Ordre de truncate respectant les FK (enfants avant parents) -const TRUNCATE_ORDER = [ - "mobility", - "ajustements", - "notes", - "ue_modules", - "enseignements", - "role_permissions", - "students", - "ue_modules", - "users", - "modules", - "ues", - "promotions", - "permissions", - "roles", -] as const; +const ALL_TABLES = + '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre. @@ -49,12 +35,9 @@ const TRUNCATE_ORDER = [ export async function truncateAll(): Promise { const client = await testPool.connect(); try { - // Désactiver les FK temporairement pour simplifier - await client.query("SET session_replication_role = replica"); - for (const table of TRUNCATE_ORDER) { - await client.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE`); - } - await client.query("SET session_replication_role = DEFAULT"); + await client.query( + `TRUNCATE TABLE ${ALL_TABLES} RESTART IDENTITY CASCADE`, + ); } finally { client.release(); } 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, +}); -- 2.52.0 From e5c6c389ea7f09b95acf691b453cce063e55d184 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:00:38 +0200 Subject: [PATCH 063/103] test(students): add unit, integration and e2e tests for /students (#109) - unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB operations - integration: real DB CRUD via testDb (list, filter, create, get, update, delete) - e2e: handler calls directly with mock FreshContext + real DB covers auth (employee vs non-employee), 400/403/404 cases - adds test:e2e deno task and CI step - adds tests/helpers/handler.ts with makeEmployeeContext, makeContextWithAffiliation, makeGetRequest, makeJsonRequest utilities Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/test.yml | 9 + .github/workflows/test.yml | 79 ++++++++ deno.json | 1 + tests/e2e/students_test.ts | 285 +++++++++++++++++++++++++++++ tests/helpers/handler.ts | 88 +++++++++ tests/integration/students_test.ts | 173 +++++++++++++++++ tests/unit/students_test.ts | 201 ++++++++++++++++++++ 7 files changed, 836 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 tests/e2e/students_test.ts create mode 100644 tests/helpers/handler.ts create mode 100644 tests/integration/students_test.ts create mode 100644 tests/unit/students_test.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6b3b830..d2a8d16 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -68,3 +68,12 @@ jobs: 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/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d2a8d16 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +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 + + - 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/deno.json b/deno.json index b3d8c09..ed7422e 100644 --- a/deno.json +++ b/deno.json @@ -13,6 +13,7 @@ "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/", "migrate": "node_modules/.bin/drizzle-kit migrate" }, "lint": { diff --git a/tests/e2e/students_test.ts b/tests/e2e/students_test.ts new file mode 100644 index 0000000..07de874 --- /dev/null +++ b/tests/e2e/students_test.ts @@ -0,0 +1,285 @@ +// #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/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/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/unit/students_test.ts b/tests/unit/students_test.ts new file mode 100644 index 0000000..2b51029 --- /dev/null +++ b/tests/unit/students_test.ts @@ -0,0 +1,201 @@ +// #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(); + } +}); -- 2.52.0 From cd5c524ff0426b31fa75db3c3c3a5ba63ce9674f Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:18:09 +0200 Subject: [PATCH 064/103] style: fix deno fmt on students tests and drizzle.config --- drizzle.config.ts | 4 +++- tests/e2e/students_test.ts | 5 ++++- tests/unit/students_test.ts | 23 +++++++++++++++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/drizzle.config.ts b/drizzle.config.ts index 27c4a86..aa57f48 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -2,7 +2,9 @@ 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}`; + `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", diff --git a/tests/e2e/students_test.ts b/tests/e2e/students_test.ts index 07de874..e02103f 100644 --- a/tests/e2e/students_test.ts +++ b/tests/e2e/students_test.ts @@ -80,7 +80,10 @@ Deno.test({ assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); - assertEquals(body.every((s: { idPromo: string }) => s.idPromo === "PEIP1-2024"), true); + assertEquals( + body.every((s: { idPromo: string }) => s.idPromo === "PEIP1-2024"), + true, + ); }, sanitizeResources: false, sanitizeOps: false, diff --git a/tests/unit/students_test.ts b/tests/unit/students_test.ts index 2b51029..ded2ff2 100644 --- a/tests/unit/students_test.ts +++ b/tests/unit/students_test.ts @@ -65,7 +65,12 @@ Deno.test("mock API: GET /students/:numEtud returns one student", async () => { }); Deno.test("mock API: GET /students/:numEtud 404 when not found", async () => { - mockFetch({ "/students/99999": { status: 404, body: { error: "Ressource introuvable" } } }); + mockFetch({ + "/students/99999": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); try { const res = await fetch("http://localhost/api/students/99999"); assertEquals(res.status, 404); @@ -81,7 +86,11 @@ Deno.test("mock API: POST /students creates student", async () => { 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" }), + body: JSON.stringify({ + nom: "Dupont", + prenom: "Jean", + idPromo: "4AFISE25/26", + }), }); assertEquals(res.status, 201); const data: Student = await res.json(); @@ -93,12 +102,18 @@ Deno.test("mock API: POST /students creates student", async () => { 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 } }); + 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" }), + body: JSON.stringify({ + nom: "Dupont-Modifié", + prenom: "Jean", + idPromo: "4AFISE25/26", + }), }); assertEquals(res.status, 200); const data: Student = await res.json(); -- 2.52.0 From e2f5bf7b95e296ac7a2e5eddc28b56d805c59880 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:30:40 +0200 Subject: [PATCH 065/103] ci: remove Run tests step from lint workflow --- .gitea/workflows/lint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index a6815e4..8bb5888 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -28,6 +28,3 @@ jobs: - name: Check linting run: deno lint - - - name: Run tests - run: deno test -A --no-check tests/ -- 2.52.0 From 222c3237f008776896a662467d6a00278eecdec7 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:03:20 +0200 Subject: [PATCH 066/103] test(promotions): add unit, integration and e2e tests for /promotions (#110) - unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB CRUD - integration: real DB list, create, get, update, delete, not-found cases - e2e: handler calls with mock context + real DB, covers 400/403/404 cases --- tests/e2e/promotions_test.ts | 209 +++++++++++++++++++++++++++ tests/integration/promotions_test.ts | 112 ++++++++++++++ tests/unit/promotions_test.ts | 153 ++++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 tests/e2e/promotions_test.ts create mode 100644 tests/integration/promotions_test.ts create mode 100644 tests/unit/promotions_test.ts diff --git a/tests/e2e/promotions_test.ts b/tests/e2e/promotions_test.ts new file mode 100644 index 0000000..9be35fb --- /dev/null +++ b/tests/e2e/promotions_test.ts @@ -0,0 +1,209 @@ +// #110 - E2E tests for /promotions endpoints + +import { assertEquals, assertExists } 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/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/unit/promotions_test.ts b/tests/unit/promotions_test.ts new file mode 100644 index 0000000..6883045 --- /dev/null +++ b/tests/unit/promotions_test.ts @@ -0,0 +1,153 @@ +// #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); +}); -- 2.52.0 From b3eb1b60a56e3c82613d700d7e529b098b820836 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:18:55 +0200 Subject: [PATCH 067/103] style: fix deno fmt and lint --- .github/workflows/deploy.yml | 27 +++++++++++++++++++++++++++ .github/workflows/lint.yml | 29 +++++++++++++++++++++++++++++ tests/e2e/promotions_test.ts | 5 ++++- tests/unit/promotions_test.ts | 11 +++++++++-- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7e44244 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,27 @@ +name: "Build and push image" + +on: + push: + branches: + - main + +jobs: + deploy: + name: "Build Docker image" + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: registry.docker.polytech.djalim.fr + username: ${{ secrets.registry_login }} + password: ${{ secrets.registry_pass }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + tags: registry.docker.polytech.djalim.fr/polympr:latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5194ae0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Check Deno code" + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +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 + + - name: Run tests + run: deno test -A --no-check tests/ diff --git a/tests/e2e/promotions_test.ts b/tests/e2e/promotions_test.ts index 9be35fb..5a3e6ff 100644 --- a/tests/e2e/promotions_test.ts +++ b/tests/e2e/promotions_test.ts @@ -57,7 +57,10 @@ Deno.test({ async fn() { await truncateAll(); const res = await promotionsHandler.POST!( - makeJsonRequest("/promotions", "POST", { idPromo: "INFO3-2025", annee: "2025" }), + makeJsonRequest("/promotions", "POST", { + idPromo: "INFO3-2025", + annee: "2025", + }), makeEmployeeContext(), ); assertEquals(res.status, 201); diff --git a/tests/unit/promotions_test.ts b/tests/unit/promotions_test.ts index 6883045..b725bc5 100644 --- a/tests/unit/promotions_test.ts +++ b/tests/unit/promotions_test.ts @@ -41,7 +41,12 @@ Deno.test("mock API: GET /promotions/:id returns one", async () => { }); Deno.test("mock API: GET /promotions/:id 404 when not found", async () => { - mockFetch({ "/promotions/UNKNOWN": { status: 404, body: { error: "Ressource introuvable" } } }); + mockFetch({ + "/promotions/UNKNOWN": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); try { const res = await fetch("http://localhost/api/promotions/UNKNOWN"); assertEquals(res.status, 404); @@ -83,7 +88,9 @@ Deno.test("mock API: POST /promotions 400 on missing fields", async () => { 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 } }); + mockFetch({ + "/promotions/4AFISE25%2F26": { method: "PUT", status: 200, body: updated }, + }); try { const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26", { method: "PUT", -- 2.52.0 From d25c3530188a6f75b8ca966e4360bef6207eeee6 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:19:11 +0200 Subject: [PATCH 068/103] fix: remove unused assertExists import --- tests/e2e/promotions_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/promotions_test.ts b/tests/e2e/promotions_test.ts index 5a3e6ff..b296229 100644 --- a/tests/e2e/promotions_test.ts +++ b/tests/e2e/promotions_test.ts @@ -1,6 +1,6 @@ // #110 - E2E tests for /promotions endpoints -import { assertEquals, assertExists } from "@std/assert"; +import { assertEquals } from "@std/assert"; import { makeContextWithAffiliation, makeEmployeeContext, -- 2.52.0 From e3eefd945c5bc73a8d080dc5209d9e33acb527d2 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 15:06:20 +0200 Subject: [PATCH 069/103] chore: remove .github workflows (act only uses .gitea) --- .github/workflows/deploy.yml | 27 --------------------------- .github/workflows/lint.yml | 29 ----------------------------- 2 files changed, 56 deletions(-) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 7e44244..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Build and push image" - -on: - push: - branches: - - main - -jobs: - deploy: - name: "Build Docker image" - runs-on: ubuntu-latest - steps: - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - registry: registry.docker.polytech.djalim.fr - username: ${{ secrets.registry_login }} - password: ${{ secrets.registry_pass }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push - uses: docker/build-push-action@v6 - with: - push: true - tags: registry.docker.polytech.djalim.fr/polympr:latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 5194ae0..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: "Check Deno code" - -on: - pull_request: - branches: - - main - -permissions: - contents: read - -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 - - - name: Run tests - run: deno test -A --no-check tests/ -- 2.52.0 From e75098083acf6252387669c80b9887cb6bbe55c7 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:06:12 +0200 Subject: [PATCH 070/103] test(roles): add unit, integration and e2e tests for /roles (#112) - unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB CRUD - integration: list, create, assign permissions, update, reset perms, delete - e2e: handler calls with mock context + real DB, covers 400/404 cases --- tests/e2e/roles_test.ts | 172 ++++++++++++++++++++++++++++++++ tests/integration/roles_test.ts | 120 ++++++++++++++++++++++ tests/unit/roles_test.ts | 155 ++++++++++++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 tests/e2e/roles_test.ts create mode 100644 tests/integration/roles_test.ts create mode 100644 tests/unit/roles_test.ts diff --git a/tests/e2e/roles_test.ts b/tests/e2e/roles_test.ts new file mode 100644 index 0000000..5decace --- /dev/null +++ b/tests/e2e/roles_test.ts @@ -0,0 +1,172 @@ +// #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/integration/roles_test.ts b/tests/integration/roles_test.ts new file mode 100644 index 0000000..c7c4307 --- /dev/null +++ b/tests/integration/roles_test.ts @@ -0,0 +1,120 @@ +// #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/unit/roles_test.ts b/tests/unit/roles_test.ts new file mode 100644 index 0000000..7cca58d --- /dev/null +++ b/tests/unit/roles_test.ts @@ -0,0 +1,155 @@ +// #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); +}); -- 2.52.0 From f038e4020bd0121d5f85489967714761a88fafa2 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:18:55 +0200 Subject: [PATCH 071/103] style: fix deno fmt and lint --- tests/e2e/roles_test.ts | 5 ++++- tests/integration/roles_test.ts | 7 +++++-- tests/unit/roles_test.ts | 8 ++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/e2e/roles_test.ts b/tests/e2e/roles_test.ts index 5decace..8026434 100644 --- a/tests/e2e/roles_test.ts +++ b/tests/e2e/roles_test.ts @@ -18,7 +18,10 @@ Deno.test({ async fn() { await truncateAll(); await seedRoles([{ nom: "admin" }, { nom: "employee" }]); - const res = await rolesHandler.GET!(makeGetRequest("/roles"), makeEmployeeContext()); + const res = await rolesHandler.GET!( + makeGetRequest("/roles"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); diff --git a/tests/integration/roles_test.ts b/tests/integration/roles_test.ts index c7c4307..9fb7a6c 100644 --- a/tests/integration/roles_test.ts +++ b/tests/integration/roles_test.ts @@ -21,7 +21,8 @@ Deno.test({ name: "integration roles: create and retrieve by id", async fn() { await truncateAll(); - const [created] = await testDb.insert(roles).values({ nom: "viewer" }).returning(); + const [created] = await testDb.insert(roles).values({ nom: "viewer" }) + .returning(); assertExists(created.id); assertEquals(created.nom, "viewer"); const row = await testDb @@ -87,7 +88,9 @@ Deno.test({ { idRole: role.id, idPermission: "note_read" }, ]); // reset - await testDb.delete(rolePermissions).where(eq(rolePermissions.idRole, role.id)); + await testDb.delete(rolePermissions).where( + eq(rolePermissions.idRole, role.id), + ); await testDb.insert(rolePermissions).values([ { idRole: role.id, idPermission: "note_write" }, ]); diff --git a/tests/unit/roles_test.ts b/tests/unit/roles_test.ts index 7cca58d..eeae55e 100644 --- a/tests/unit/roles_test.ts +++ b/tests/unit/roles_test.ts @@ -58,7 +58,9 @@ Deno.test("mock API: GET /roles/:id returns role", async () => { }); Deno.test("mock API: GET /roles/:id 404 when not found", async () => { - mockFetch({ "/roles/99": { status: 404, body: { error: "Ressource introuvable" } } }); + mockFetch({ + "/roles/99": { status: 404, body: { error: "Ressource introuvable" } }, + }); try { const res = await fetch("http://localhost/api/roles/99"); assertEquals(res.status, 404); @@ -120,7 +122,9 @@ Deno.test("mock API: PUT /roles/:id updates role and permissions", async () => { 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" }); + const res = await fetch("http://localhost/api/roles/2", { + method: "DELETE", + }); assertEquals(res.status, 204); } finally { restoreFetch(); -- 2.52.0 From c86d20ca813e6a2b7ab9a1af3b20457739ac1c5b Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:07:17 +0200 Subject: [PATCH 072/103] test(modules): add unit, integration and e2e tests for /modules (#113) - unit: fixture shapes, mock API (GET/POST/PUT/DELETE + 409), mock DB CRUD - integration: list, create, get, duplicate rejection, update, delete - e2e: handler calls with mock context + real DB, covers 400/403/404/409 --- tests/e2e/modules_test.ts | 204 ++++++++++++++++++++++++++++++ tests/integration/modules_test.ts | 98 ++++++++++++++ tests/unit/modules_test.ts | 151 ++++++++++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 tests/e2e/modules_test.ts create mode 100644 tests/integration/modules_test.ts create mode 100644 tests/unit/modules_test.ts diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts new file mode 100644 index 0000000..bc8f1c8 --- /dev/null +++ b/tests/e2e/modules_test.ts @@ -0,0 +1,204 @@ +// #113 - E2E tests for /modules endpoints + +import { assertEquals, assertExists } 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 empty 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, 0); + }, + 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/integration/modules_test.ts b/tests/integration/modules_test.ts new file mode 100644 index 0000000..f0acc7f --- /dev/null +++ b/tests/integration/modules_test.ts @@ -0,0 +1,98 @@ +// #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/unit/modules_test.ts b/tests/unit/modules_test.ts new file mode 100644 index 0000000..c9f2276 --- /dev/null +++ b/tests/unit/modules_test.ts @@ -0,0 +1,151 @@ +// #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); +}); -- 2.52.0 From c5d02a2890f9b943372cf62a5c4ca60f1bcaedfc Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:18:55 +0200 Subject: [PATCH 073/103] style: fix deno fmt and lint --- tests/e2e/modules_test.ts | 10 ++++++++-- tests/integration/modules_test.ts | 10 ++++++++-- tests/unit/modules_test.ts | 32 +++++++++++++++++++++++++------ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts index bc8f1c8..21faee7 100644 --- a/tests/e2e/modules_test.ts +++ b/tests/e2e/modules_test.ts @@ -17,8 +17,14 @@ 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()); + 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); diff --git a/tests/integration/modules_test.ts b/tests/integration/modules_test.ts index f0acc7f..df32fba 100644 --- a/tests/integration/modules_test.ts +++ b/tests/integration/modules_test.ts @@ -9,7 +9,10 @@ Deno.test({ name: "integration modules: list all modules", async fn() { await truncateAll(); - await seedModules([{ id: "MATH101", nom: "Mathématiques" }, { id: "INFO101", nom: "Informatique" }]); + await seedModules([{ id: "MATH101", nom: "Mathématiques" }, { + id: "INFO101", + nom: "Informatique", + }]); const rows = await testDb.select().from(modules); assertEquals(rows.length, 2); }, @@ -21,7 +24,10 @@ 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(); + const [created] = await testDb.insert(modules).values({ + id: "PHYS101", + nom: "Physique", + }).returning(); assertExists(created); assertEquals(created.id, "PHYS101"); diff --git a/tests/unit/modules_test.ts b/tests/unit/modules_test.ts index c9f2276..e94cdc4 100644 --- a/tests/unit/modules_test.ts +++ b/tests/unit/modules_test.ts @@ -42,7 +42,12 @@ Deno.test("mock API: GET /modules/:id returns one module", async () => { }); Deno.test("mock API: GET /modules/:id 404 when not found", async () => { - mockFetch({ "/modules/UNKNOWN": { status: 404, body: { error: "Ressource introuvable" } } }); + mockFetch({ + "/modules/UNKNOWN": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); try { const res = await fetch("http://localhost/api/modules/UNKNOWN"); assertEquals(res.status, 404); @@ -69,7 +74,13 @@ Deno.test("mock API: POST /modules creates module (201)", async () => { }); 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à" } } }); + 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", @@ -98,7 +109,9 @@ Deno.test("mock API: POST /modules 400 on missing fields", async () => { 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 } }); + mockFetch({ + "/modules/JIN702C": { method: "PUT", status: 200, body: updated }, + }); try { const res = await fetch("http://localhost/api/modules/JIN702C", { method: "PUT", @@ -116,7 +129,9 @@ Deno.test("mock API: PUT /modules/:id updates nom", async () => { 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" }); + const res = await fetch("http://localhost/api/modules/JIN702C", { + method: "DELETE", + }); assertEquals(res.status, 204); } finally { restoreFetch(); @@ -140,8 +155,13 @@ Deno.test("mock DB: insert module", () => { 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"); + 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", () => { -- 2.52.0 From e3a7e20993495322a7d759a4eaa1f51855aef940 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:19:11 +0200 Subject: [PATCH 074/103] fix: remove unused assertExists import --- tests/e2e/modules_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts index 21faee7..7b33ca0 100644 --- a/tests/e2e/modules_test.ts +++ b/tests/e2e/modules_test.ts @@ -1,6 +1,6 @@ // #113 - E2E tests for /modules endpoints -import { assertEquals, assertExists } from "@std/assert"; +import { assertEquals } from "@std/assert"; import { makeContextWithAffiliation, makeEmployeeContext, -- 2.52.0 From 86080b8042f22bc47a4bf93aa4db552764fd07a8 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:07:51 +0200 Subject: [PATCH 075/103] test(permissions): add unit and e2e tests for GET /permissions (#115) Handler is static (no DB), tests verify the 9 known permissions are returned with correct id/nom shapes. --- tests/e2e/permissions_test.ts | 44 +++++++++++++++++++++++ tests/unit/permissions_test.ts | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/e2e/permissions_test.ts create mode 100644 tests/unit/permissions_test.ts diff --git a/tests/e2e/permissions_test.ts b/tests/e2e/permissions_test.ts new file mode 100644 index 0000000..73efe1d --- /dev/null +++ b/tests/e2e/permissions_test.ts @@ -0,0 +1,44 @@ +// #115 - E2E tests for GET /permissions +// Handler statique (pas de DB), test direct du handler + +import { assertEquals, assertExists } from "@std/assert"; +import { makeEmployeeContext, makeGetRequest } from "../helpers/handler.ts"; +import { handler as permissionsHandler } from "$apps/admin/api/permissions.ts"; + +Deno.test({ + name: "e2e permissions: GET /permissions returns all 9 permissions", + fn() { + const res = permissionsHandler.GET!( + makeGetRequest("/permissions"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = JSON.parse(res.body ? "" : "[]"); + // handler returns synchronously — parse via text + return res.text().then((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() { + const res = 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/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(); + } +}); -- 2.52.0 From a3b55d0a1b1befb98870fbe314b815b17f1beda5 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:19:26 +0200 Subject: [PATCH 076/103] fix: remove unused body variable in permissions e2e test --- tests/e2e/permissions_test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/permissions_test.ts b/tests/e2e/permissions_test.ts index 73efe1d..158c82a 100644 --- a/tests/e2e/permissions_test.ts +++ b/tests/e2e/permissions_test.ts @@ -13,8 +13,6 @@ Deno.test({ makeEmployeeContext(), ); assertEquals(res.status, 200); - const body = JSON.parse(res.body ? "" : "[]"); - // handler returns synchronously — parse via text return res.text().then((text) => { const data = JSON.parse(text); assertEquals(data.length, 9); -- 2.52.0 From 2f4d8db1bf8a2760ea10eb08697b0d246a2aa8bb Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 18:25:00 +0200 Subject: [PATCH 077/103] test: add full test coverage for notes, ues, ue-modules, ajustements, enseignements, users - Unit tests (mock DB + API) for all missing endpoints - Integration tests (Drizzle direct) for all missing entities - E2E tests (handler + real DB) for all missing endpoints - Robustness tests: invalid inputs, SQL injection, type errors, business rule violations - Seed helpers: seedNotes, seedUeModules, seedEnseignements, seedAjustements - Add test:coverage and test:coverage:html tasks to deno.json Tests expose known handler bugs (marked [BUG] in test names): - ajustements PUT/DELETE: .where() without and() modifies all rows for student - Missing try/catch in modules, users, enseignements handlers - Whitespace accepted as valid string values - No type or business rule validation (note bounds, coeff >= 0) --- deno.json | 2 + tests/e2e/ajustements_test.ts | 301 +++++++++++++ tests/e2e/enseignements_test.ts | 185 ++++++++ tests/e2e/notes_test.ts | 244 +++++++++++ tests/e2e/robustness_test.ts | 559 ++++++++++++++++++++++++ tests/e2e/ue_modules_test.ts | 271 ++++++++++++ tests/e2e/ues_test.ts | 170 +++++++ tests/e2e/users_test.ts | 217 +++++++++ tests/helpers/db_integration.ts | 24 + tests/integration/ajustements_test.ts | 127 ++++++ tests/integration/enseignements_test.ts | 135 ++++++ tests/integration/notes_test.ts | 124 ++++++ tests/integration/ue_modules_test.ts | 132 ++++++ tests/integration/ues_test.ts | 83 ++++ tests/unit/ajustements_test.ts | 189 ++++++++ tests/unit/enseignements_test.ts | 179 ++++++++ tests/unit/notes_test.ts | 196 +++++++++ tests/unit/ue_modules_test.ts | 173 ++++++++ tests/unit/ues_test.ts | 160 +++++++ 19 files changed, 3471 insertions(+) create mode 100644 tests/e2e/ajustements_test.ts create mode 100644 tests/e2e/enseignements_test.ts create mode 100644 tests/e2e/notes_test.ts create mode 100644 tests/e2e/robustness_test.ts create mode 100644 tests/e2e/ue_modules_test.ts create mode 100644 tests/e2e/ues_test.ts create mode 100644 tests/e2e/users_test.ts create mode 100644 tests/integration/ajustements_test.ts create mode 100644 tests/integration/enseignements_test.ts create mode 100644 tests/integration/notes_test.ts create mode 100644 tests/integration/ue_modules_test.ts create mode 100644 tests/integration/ues_test.ts create mode 100644 tests/unit/ajustements_test.ts create mode 100644 tests/unit/enseignements_test.ts create mode 100644 tests/unit/notes_test.ts create mode 100644 tests/unit/ue_modules_test.ts create mode 100644 tests/unit/ues_test.ts diff --git a/deno.json b/deno.json index ed7422e..97ab295 100644 --- a/deno.json +++ b/deno.json @@ -14,6 +14,8 @@ "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": { diff --git a/tests/e2e/ajustements_test.ts b/tests/e2e/ajustements_test.ts new file mode 100644 index 0000000..8b07a04 --- /dev/null +++ b/tests/e2e/ajustements_test.ts @@ -0,0 +1,301 @@ +// 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..77751f4 --- /dev/null +++ b/tests/e2e/enseignements_test.ts @@ -0,0 +1,185 @@ +// 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/notes_test.ts b/tests/e2e/notes_test.ts new file mode 100644 index 0000000..78be912 --- /dev/null +++ b/tests/e2e/notes_test.ts @@ -0,0 +1,244 @@ +// 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/robustness_test.ts b/tests/e2e/robustness_test.ts new file mode 100644 index 0000000..ba18a1d --- /dev/null +++ b/tests/e2e/robustness_test.ts @@ -0,0 +1,559 @@ +// 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, assertRejects } 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/notes/api/ues.ts"; +import { handler as ueModulesHandler } from "$apps/notes/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, +}); + +// Handlers SANS try/catch — throwent au lieu de retourner 500 +// [BUG] Ces handlers devraient retourner 500, pas throw + +Deno.test({ + name: "robustness [BUG]: POST /modules malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + modulesHandler.POST!( + makeMalformedRequest("/modules"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /enseignements malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + enseignementsHandler.POST!( + makeMalformedRequest("/enseignements"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /users malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + usersHandler.POST!( + makeMalformedRequest("/users"), + makeEmployeeContext(), + ) + ); + }, + 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 [BUG]: POST /modules sans body → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + modulesHandler.POST!( + makeEmptyBodyRequest("/modules"), + makeEmployeeContext(), + ) + ); + }, + 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 [BUG]: POST /modules id=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: " ", nom: "Test" }), + makeEmployeeContext(), + ); + // Le handler vérifie !body.id → " " est truthy → passe → s'insère + // Comportement attendu : 400 + // Comportement réel : 201 (bug : pas de trim()) + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ues nom=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: " " }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /users id=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + await assertRejects( + // sans try/catch + whitespace id → s'insère (ou throw si DB rejette) + // Dans tous les cas le handler ne valide pas correctement + async () => { + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), + makeEmployeeContext(), + ); + // Si pas de throw : le handler a inséré des espaces en DB + assertEquals(res.status, 400); + }, + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// MAUVAIS TYPES +// ============================================================================= + +Deno.test({ + name: "robustness [BUG]: POST /notes note=string → devrait être 400, retourne 500", + 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(), + ); + // "pas-un-nombre" !== undefined → passe la validation → DB rejette → 500 + // Comportement attendu : 400 (validation de type) + // Comportement réel : 500 + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: PUT /modules/:id nom=number → devrait être 400, throw ou insère", + async fn() { + await truncateAll(); + await seedModules([{ id: "M1", nom: "Mod" }]); + await assertRejects(() => + moduleHandler.PUT!( + makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), + makeEmployeeContext({ idModule: "M1" }), + ) + ); + }, + 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 [BUG]: POST /notes note > 20 → devrait être 400, retourne 201", + 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(), + ); + // Aucune validation de borne → 999 s'insère → 201 + assertEquals(res.status, 400); // ← va échouer, expose le manque de validation métier + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /notes note < 0 → devrait être 400, retourne 201", + 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); // ← va échouer + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ue-modules coeff négatif → devrait être 400, retourne 201", + 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); // ← va échouer + }, + 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/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts new file mode 100644 index 0000000..028dfa8 --- /dev/null +++ b/tests/e2e/ue_modules_test.ts @@ -0,0 +1,271 @@ +// 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/notes/api/ue-modules.ts"; +import { handler as ueModuleHandler } from "$apps/notes/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..a57249b --- /dev/null +++ b/tests/e2e/ues_test.ts @@ -0,0 +1,170 @@ +// 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/notes/api/ues.ts"; +import { handler as ueHandler } from "$apps/notes/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..038ed2f --- /dev/null +++ b/tests/e2e/users_test.ts @@ -0,0 +1,217 @@ +// 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/db_integration.ts b/tests/helpers/db_integration.ts index ee7fe04..4b91b25 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -87,3 +87,27 @@ export async function seedUsers( ): 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(); +} diff --git a/tests/integration/ajustements_test.ts b/tests/integration/ajustements_test.ts new file mode 100644 index 0000000..cd032a8 --- /dev/null +++ b/tests/integration/ajustements_test.ts @@ -0,0 +1,127 @@ +// 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..c48a312 --- /dev/null +++ b/tests/integration/enseignements_test.ts @@ -0,0 +1,135 @@ +// 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/notes_test.ts b/tests/integration/notes_test.ts new file mode 100644 index 0000000..bae19b3 --- /dev/null +++ b/tests/integration/notes_test.ts @@ -0,0 +1,124 @@ +// 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/ue_modules_test.ts b/tests/integration/ue_modules_test.ts new file mode 100644 index 0000000..9a996ad --- /dev/null +++ b/tests/integration/ue_modules_test.ts @@ -0,0 +1,132 @@ +// 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..653fbef --- /dev/null +++ b/tests/integration/ues_test.ts @@ -0,0 +1,83 @@ +// 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/unit/ajustements_test.ts b/tests/unit/ajustements_test.ts new file mode 100644 index 0000000..a2786c4 --- /dev/null +++ b/tests/unit/ajustements_test.ts @@ -0,0 +1,189 @@ +// 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..3182019 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,179 @@ +// 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/notes_test.ts b/tests/unit/notes_test.ts new file mode 100644 index 0000000..f39d4ba --- /dev/null +++ b/tests/unit/notes_test.ts @@ -0,0 +1,196 @@ +// 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/ue_modules_test.ts b/tests/unit/ue_modules_test.ts new file mode 100644 index 0000000..8037998 --- /dev/null +++ b/tests/unit/ue_modules_test.ts @@ -0,0 +1,173 @@ +// 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..5d36a95 --- /dev/null +++ b/tests/unit/ues_test.ts @@ -0,0 +1,160 @@ +// 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); +}); -- 2.52.0 From b0930b8da257842564f07acdefbfcbe9cd8bd363 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 19:01:53 +0200 Subject: [PATCH 078/103] fix: correct handler bugs exposed by test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ajustements [numEtud]/[idUE]: fix .where() missing and() — PUT/DELETE were applying only numEtud condition, modifying all rows for a student - modules/users/enseignements POST: add try/catch, return 500 on invalid JSON - modules/[idModule] PUT: add try/catch + type check on nom (string required) - modules POST: add .trim() check to reject whitespace-only id/nom - users POST: add .trim() check to reject whitespace-only id/nom/prenom - ues POST: add .trim() check to reject whitespace-only nom - notes POST: add type check (typeof number) and bounds check (0 ≤ note ≤ 20) - ue-modules POST: add coeff >= 0 validation Update robustness tests to reflect fixed behavior (remove [BUG] labels, replace assertRejects with status code assertions). --- routes/(apps)/admin/api/enseignements.ts | 11 +- routes/(apps)/admin/api/modules.ts | 9 +- routes/(apps)/admin/api/modules/[idModule].ts | 11 +- routes/(apps)/admin/api/users.ts | 13 +- .../notes/api/ajustements/[numEtud]/[idUE].ts | 8 +- routes/(apps)/notes/api/notes.ts | 6 + routes/(apps)/notes/api/ue-modules.ts | 6 + routes/(apps)/notes/api/ues.ts | 2 +- tests/e2e/robustness_test.ts | 199 ++++++++++-------- 9 files changed, 166 insertions(+), 99 deletions(-) diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index 06408bc..cb2ab47 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -26,11 +26,12 @@ export const handler: Handlers = { return FORBIDDEN; } - const body: { - idProf: string; - idModule: string; - idPromo: string; - } = await request.json(); + 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 }); diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 2cb2fe7..bdb37b9 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -31,9 +31,14 @@ export const handler: Handlers = { return new Response(null, { status: 403 }); } - const body: { id: string; nom: string } = await request.json(); + let body: { id: string; nom: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } - if (!body.id || !body.nom) { + if (!body.id || !body.id.trim() || !body.nom || !body.nom.trim()) { return new Response(null, { status: 400 }); } diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index 6f17dfe..d3d9467 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -33,7 +33,16 @@ export const handler: Handlers = { request: Request, context: FreshContext, ): Promise { - const body: { nom: string } = await request.json(); + 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) diff --git a/routes/(apps)/admin/api/users.ts b/routes/(apps)/admin/api/users.ts index d2fbd56..61317d7 100644 --- a/routes/(apps)/admin/api/users.ts +++ b/routes/(apps)/admin/api/users.ts @@ -27,10 +27,17 @@ export const handler: Handlers = { request: Request, _context: FreshContext, ): Promise { - const body: { id: string; nom: string; prenom: string; idRole: number } = - await request.json(); + 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.nom || !body.prenom) { + if ( + !body.id || !body.id.trim() || !body.nom || !body.nom.trim() || + !body.prenom || !body.prenom.trim() + ) { return new Response(null, { status: 400 }); } diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index c9b3ab0..a165f44 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -2,7 +2,7 @@ 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"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ajustement introuvable" }), @@ -31,7 +31,7 @@ export const handler: Handlers = { const ajustement = await db .select() .from(ajustements) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .then((rows) => rows[0] ?? null); if (!ajustement) return NOT_FOUND; @@ -69,7 +69,7 @@ export const handler: Handlers = { const [updated] = await db .update(ajustements) .set({ valeur: body.valeur }) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); if (!updated) return NOT_FOUND; @@ -97,7 +97,7 @@ export const handler: Handlers = { const [deleted] = await db .delete(ajustements) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); if (!deleted) return NOT_FOUND; diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index 22d387e..b7fd580 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -49,6 +49,12 @@ export const handler: Handlers = { }); } + if (typeof note !== "number" || note < 0 || note > 20) { + return new Response("Champ 'note' doit être un nombre entre 0 et 20", { + status: 400, + }); + } + const result = await db.insert(notes).values({ note, numEtud, idModule }) .returning(); diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index 8cd48bc..1a825a6 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -47,6 +47,12 @@ export const handler: Handlers = { ); } + 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, diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 757245c..92242da 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -24,7 +24,7 @@ export const handler: Handlers = { const body = await request.json(); const { nom } = body; - if (!nom) { + if (!nom || !nom.trim()) { return new Response("Champ 'nom' manquant", { status: 400 }); } diff --git a/tests/e2e/robustness_test.ts b/tests/e2e/robustness_test.ts index ba18a1d..fb5552b 100644 --- a/tests/e2e/robustness_test.ts +++ b/tests/e2e/robustness_test.ts @@ -4,7 +4,7 @@ // Les tests marqués [BUG] représentent le comportement ATTENDU — ils échouent // intentionnellement pour exposer un bug dans le handler ciblé. -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals } from "@std/assert"; import { makeContextWithAffiliation, makeEmployeeContext, @@ -90,7 +90,8 @@ Deno.test({ }); Deno.test({ - name: "robustness: POST /ajustements malformed JSON → 500 (try/catch présent)", + name: + "robustness: POST /ajustements malformed JSON → 500 (try/catch présent)", async fn() { await truncateAll(); const res = await ajustementsHandler.POST!( @@ -103,49 +104,43 @@ Deno.test({ sanitizeOps: false, }); -// Handlers SANS try/catch — throwent au lieu de retourner 500 -// [BUG] Ces handlers devraient retourner 500, pas throw - Deno.test({ - name: "robustness [BUG]: POST /modules malformed JSON → throw (pas de try/catch)", + name: "robustness: POST /modules malformed JSON → 500", async fn() { await truncateAll(); - await assertRejects(() => - modulesHandler.POST!( - makeMalformedRequest("/modules"), - makeEmployeeContext(), - ) + const res = await modulesHandler.POST!( + makeMalformedRequest("/modules"), + makeEmployeeContext(), ); + assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /enseignements malformed JSON → throw (pas de try/catch)", + name: "robustness: POST /enseignements malformed JSON → 500", async fn() { await truncateAll(); - await assertRejects(() => - enseignementsHandler.POST!( - makeMalformedRequest("/enseignements"), - makeEmployeeContext(), - ) + const res = await enseignementsHandler.POST!( + makeMalformedRequest("/enseignements"), + makeEmployeeContext(), ); + assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /users malformed JSON → throw (pas de try/catch)", + name: "robustness: POST /users malformed JSON → 500", async fn() { await truncateAll(); - await assertRejects(() => - usersHandler.POST!( - makeMalformedRequest("/users"), - makeEmployeeContext(), - ) + const res = await usersHandler.POST!( + makeMalformedRequest("/users"), + makeEmployeeContext(), ); + assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, @@ -170,15 +165,14 @@ Deno.test({ }); Deno.test({ - name: "robustness [BUG]: POST /modules sans body → throw (pas de try/catch)", + name: "robustness: POST /modules sans body → 500", async fn() { await truncateAll(); - await assertRejects(() => - modulesHandler.POST!( - makeEmptyBodyRequest("/modules"), - makeEmployeeContext(), - ) + const res = await modulesHandler.POST!( + makeEmptyBodyRequest("/modules"), + makeEmployeeContext(), ); + assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, @@ -235,52 +229,42 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness [BUG]: POST /modules id=espaces → devrait être 400, retourne 201", + name: "robustness: POST /modules id=espaces → 400", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: " ", nom: "Test" }), makeEmployeeContext(), ); - // Le handler vérifie !body.id → " " est truthy → passe → s'insère - // Comportement attendu : 400 - // Comportement réel : 201 (bug : pas de trim()) - assertEquals(res.status, 400); // ← va échouer, expose le bug + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /ues nom=espaces → devrait être 400, retourne 201", + 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); // ← va échouer, expose le bug + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /users id=espaces → devrait être 400, retourne 201", + name: "robustness: POST /users id=espaces → 400", async fn() { await truncateAll(); - await assertRejects( - // sans try/catch + whitespace id → s'insère (ou throw si DB rejette) - // Dans tous les cas le handler ne valide pas correctement - async () => { - const res = await usersHandler.POST!( - makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), - makeEmployeeContext(), - ); - // Si pas de throw : le handler a inséré des espaces en DB - assertEquals(res.status, 400); - }, + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), + makeEmployeeContext(), ); + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, @@ -291,36 +275,40 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness [BUG]: POST /notes note=string → devrait être 400, retourne 500", + 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" }]); + 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" }), + makeJsonRequest("/notes", "POST", { + note: "pas-un-nombre", + numEtud: s.numEtud, + idModule: "M1", + }), makeEmployeeContext(), ); - // "pas-un-nombre" !== undefined → passe la validation → DB rejette → 500 - // Comportement attendu : 400 (validation de type) - // Comportement réel : 500 - assertEquals(res.status, 400); // ← va échouer, expose le bug + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: PUT /modules/:id nom=number → devrait être 400, throw ou insère", + name: "robustness: PUT /modules/:id nom=number → 400", async fn() { await truncateAll(); await seedModules([{ id: "M1", nom: "Mod" }]); - await assertRejects(() => - moduleHandler.PUT!( - makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), - makeEmployeeContext({ idModule: "M1" }), - ) + const res = await moduleHandler.PUT!( + makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), + makeEmployeeContext({ idModule: "M1" }), ); + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, @@ -331,12 +319,17 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness [BUG]: POST /ajustements numEtud=0 → 400 pour mauvaise raison", + 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 }), + 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 @@ -353,9 +346,17 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "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 }), + makeJsonRequest("/ajustements", "POST", { + numEtud: s.numEtud, + idUE: 0, + valeur: 10.0, + }), makeEmployeeContext(), ); assertEquals(res.status, 400); // !0 → 400, message trompeur @@ -369,14 +370,20 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness: POST /ue-modules coeff=0 → 201 (zéro est une valeur valide)", + 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 }), + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 0, + }), makeEmployeeContext(), ); // coeff === undefined → false pour 0 → passe ✓ @@ -392,7 +399,8 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness: GET /modules avec SQL injection dans id → 404 (Drizzle paramètre)", + name: + "robustness: GET /modules avec SQL injection dans id → 404 (Drizzle paramètre)", async fn() { await truncateAll(); const injectionId = "'; DROP TABLE modules; --"; @@ -409,7 +417,8 @@ Deno.test({ }); Deno.test({ - name: "robustness: POST /modules avec SQL injection dans id → s'insère littéralement (safe)", + name: + "robustness: POST /modules avec SQL injection dans id → s'insère littéralement (safe)", async fn() { await truncateAll(); const injectionId = "'; DROP TABLE modules; --"; @@ -429,52 +438,72 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "robustness [BUG]: POST /notes note > 20 → devrait être 400, retourne 201", + 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" }]); + 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" }), + makeJsonRequest("/notes", "POST", { + note: 999, + numEtud: s.numEtud, + idModule: "M1", + }), makeEmployeeContext(), ); - // Aucune validation de borne → 999 s'insère → 201 - assertEquals(res.status, 400); // ← va échouer, expose le manque de validation métier + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /notes note < 0 → devrait être 400, retourne 201", + 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" }]); + 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" }), + makeJsonRequest("/notes", "POST", { + note: -5, + numEtud: s.numEtud, + idModule: "M1", + }), makeEmployeeContext(), ); - assertEquals(res.status, 400); // ← va échouer + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ - name: "robustness [BUG]: POST /ue-modules coeff négatif → devrait être 400, retourne 201", + 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 }), + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: -3, + }), makeEmployeeContext(), ); - assertEquals(res.status, 400); // ← va échouer + assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, @@ -491,7 +520,10 @@ Deno.test({ // Ce test crée un module await truncateAll(); await modulesHandler.POST!( - makeJsonRequest("/modules", "POST", { id: "ISOLATION-TEST", nom: "Test" }), + makeJsonRequest("/modules", "POST", { + id: "ISOLATION-TEST", + nom: "Test", + }), makeEmployeeContext(), ); }, @@ -500,7 +532,8 @@ Deno.test({ }); Deno.test({ - name: "robustness: isolation — truncateAll efface bien les données du test précédent", + 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 -- 2.52.0 From 714486f43c1e72b8c63878cbee7f9177cba9654f Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 19:07:15 +0200 Subject: [PATCH 079/103] chore: formated tests --- tests/e2e/ajustements_test.ts | 84 +++++++++++++++---- tests/e2e/enseignements_test.ts | 93 ++++++++++++++++----- tests/e2e/notes_test.ts | 59 +++++++++++--- tests/e2e/ue_modules_test.ts | 75 +++++++++++++---- tests/e2e/ues_test.ts | 14 +++- tests/e2e/users_test.ts | 34 ++++++-- tests/integration/ajustements_test.ts | 55 ++++++++++--- tests/integration/enseignements_test.ts | 21 ++++- tests/integration/notes_test.ts | 46 +++++++++-- tests/integration/ue_modules_test.ts | 71 +++++++++++++--- tests/integration/ues_test.ts | 19 +++-- tests/unit/ajustements_test.ts | 59 +++++++++++--- tests/unit/enseignements_test.ts | 104 +++++++++++++++++++----- tests/unit/notes_test.ts | 46 +++++++++-- tests/unit/ue_modules_test.ts | 87 +++++++++++++++----- tests/unit/ues_test.ts | 8 +- 16 files changed, 699 insertions(+), 176 deletions(-) diff --git a/tests/e2e/ajustements_test.ts b/tests/e2e/ajustements_test.ts index 8b07a04..2ca2ef7 100644 --- a/tests/e2e/ajustements_test.ts +++ b/tests/e2e/ajustements_test.ts @@ -26,10 +26,17 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "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()); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 1); @@ -43,8 +50,16 @@ Deno.test({ 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 [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 }, @@ -80,14 +95,23 @@ Deno.test({ // --- POST /ajustements --- Deno.test({ - name: "e2e ajustements: POST /ajustements creates ajustement (201) as employee", + 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 [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 }), + makeJsonRequest("/ajustements", "POST", { + numEtud: s.numEtud, + idUE: ue.id, + valeur: 14.5, + }), makeEmployeeContext(), ); assertEquals(res.status, 201); @@ -104,7 +128,11 @@ Deno.test({ async fn() { await truncateAll(); const res = await ajustementsHandler.POST!( - makeJsonRequest("/ajustements", "POST", { numEtud: 1, idUE: 1, valeur: 10.0 }), + makeJsonRequest("/ajustements", "POST", { + numEtud: 1, + idUE: 1, + valeur: 10.0, + }), makeContextWithAffiliation("student"), ); assertEquals(res.status, 403); @@ -130,7 +158,8 @@ Deno.test({ // --- GET /ajustements/:numEtud/:idUE --- Deno.test({ - name: "e2e ajustements: GET /ajustements/:numEtud/:idUE returns correct ajustement (employee)", + name: + "e2e ajustements: GET /ajustements/:numEtud/:idUE returns correct ajustement (employee)", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); @@ -147,7 +176,10 @@ Deno.test({ ]); const res = await ajustementHandler.GET!( makeGetRequest(`/ajustements/${s1.numEtud}/${ue1.id}`), - makeEmployeeContext({ numEtud: String(s1.numEtud), idUE: String(ue1.id) }), + makeEmployeeContext({ + numEtud: String(s1.numEtud), + idUE: String(ue1.id), + }), ); assertEquals(res.status, 200); const body = await res.json(); @@ -189,19 +221,28 @@ Deno.test({ // --- PUT /ajustements/:numEtud/:idUE --- Deno.test({ - name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE updates only targeted row (employee)", + 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" }]); + 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 }), + makeJsonRequest(`/ajustements/${s.numEtud}/${ue1.id}`, "PUT", { + valeur: 19.0, + }), makeEmployeeContext({ numEtud: String(s.numEtud), idUE: String(ue1.id) }), ); assertEquals(res.status, 200); @@ -247,11 +288,16 @@ Deno.test({ // --- DELETE /ajustements/:numEtud/:idUE --- Deno.test({ - name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE deletes only targeted row (employee)", + 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 [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([ @@ -273,7 +319,8 @@ Deno.test({ }); Deno.test({ - name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 403 for non-employee", + name: + "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 403 for non-employee", async fn() { await truncateAll(); const res = await ajustementHandler.DELETE!( @@ -287,7 +334,8 @@ Deno.test({ }); Deno.test({ - name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 404 when not found", + name: + "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 404 when not found", async fn() { await truncateAll(); const res = await ajustementHandler.DELETE!( diff --git a/tests/e2e/enseignements_test.ts b/tests/e2e/enseignements_test.ts index 77751f4..32c9326 100644 --- a/tests/e2e/enseignements_test.ts +++ b/tests/e2e/enseignements_test.ts @@ -20,14 +20,19 @@ import { handler as enseignementHandler } from "$apps/admin/api/enseignements/[i // --- POST /enseignements --- Deno.test({ - name: "e2e enseignements: POST /enseignements creates enseignement (201) as employee", + 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" }), + makeJsonRequest("/enseignements", "POST", { + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), makeEmployeeContext(), ); assertEquals(res.status, 201); @@ -44,7 +49,11 @@ Deno.test({ async fn() { await truncateAll(); const res = await enseignementsHandler.POST!( - makeJsonRequest("/enseignements", "POST", { idProf: "p", idModule: "M1", idPromo: "P1" }), + makeJsonRequest("/enseignements", "POST", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), makeContextWithAffiliation("student"), ); assertEquals(res.status, 403); @@ -74,9 +83,17 @@ Deno.test({ 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 seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); const res = await enseignementsHandler.POST!( - makeJsonRequest("/enseignements", "POST", { idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }), + makeJsonRequest("/enseignements", "POST", { + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), makeEmployeeContext(), ); assertEquals(res.status, 409); @@ -88,16 +105,25 @@ Deno.test({ // --- GET /enseignements/:idProf/:idModule/:idPromo --- Deno.test({ - name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", + 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" }]); + 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" }), + makeEmployeeContext({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), ); assertEquals(res.status, 200); const body = await res.json(); @@ -109,12 +135,17 @@ Deno.test({ }); Deno.test({ - name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + 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" }), + makeContextWithAffiliation("student", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), ); assertEquals(res.status, 403); }, @@ -123,12 +154,17 @@ Deno.test({ }); Deno.test({ - name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 404 when not found", + 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" }), + makeEmployeeContext({ + idProf: "ghost", + idModule: "GHOST", + idPromo: "GHOST", + }), ); assertEquals(res.status, 404); }, @@ -139,16 +175,25 @@ Deno.test({ // --- DELETE /enseignements/:idProf/:idModule/:idPromo --- Deno.test({ - name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo returns 204 (employee)", + 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" }]); + 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" }), + makeEmployeeContext({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }), ); assertEquals(res.status, 204); }, @@ -157,12 +202,17 @@ Deno.test({ }); Deno.test({ - name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + 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" }), + makeContextWithAffiliation("student", { + idProf: "p", + idModule: "M1", + idPromo: "P1", + }), ); assertEquals(res.status, 403); }, @@ -171,12 +221,17 @@ Deno.test({ }); Deno.test({ - name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 404 when not found", + 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" }), + makeEmployeeContext({ + idProf: "ghost", + idModule: "GHOST", + idPromo: "GHOST", + }), ); assertEquals(res.status, 404); }, diff --git a/tests/e2e/notes_test.ts b/tests/e2e/notes_test.ts index 78be912..ee1f491 100644 --- a/tests/e2e/notes_test.ts +++ b/tests/e2e/notes_test.ts @@ -23,13 +23,20 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "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()); + const res = await notesHandler.GET!( + makeGetRequest("/notes"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); @@ -43,8 +50,16 @@ Deno.test({ 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 [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 }, @@ -82,7 +97,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "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 }, @@ -108,10 +127,18 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Leroy", prenom: "Paul", idPromo: "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 }), + makeJsonRequest("/notes", "POST", { + numEtud: s.numEtud, + idModule: "M1", + note: 14.0, + }), makeEmployeeContext(), ); assertEquals(res.status, 201); @@ -144,7 +171,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "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!( @@ -180,7 +211,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "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!( @@ -216,7 +251,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Petit", prenom: "Hugo", idPromo: "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!( diff --git a/tests/e2e/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts index 028dfa8..3a921f8 100644 --- a/tests/e2e/ue_modules_test.ts +++ b/tests/e2e/ue_modules_test.ts @@ -32,7 +32,10 @@ Deno.test({ { 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()); + const res = await ueModulesHandler.GET!( + makeGetRequest("/ue-modules"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); @@ -75,7 +78,12 @@ Deno.test({ 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 }), + makeJsonRequest("/ue-modules", "POST", { + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 4.0, + }), makeEmployeeContext(), ); assertEquals(res.status, 201); @@ -104,7 +112,8 @@ Deno.test({ // --- GET /ue-modules/:idModule/:idUE/:idPromo --- Deno.test({ - name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo returns correct association (employee)", + name: + "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo returns correct association (employee)", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }, { id: "P2" }]); @@ -119,7 +128,11 @@ Deno.test({ ]); const res = await ueModuleHandler.GET!( makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), - makeEmployeeContext({ idModule: "M1", idUE: String(ue1.id), idPromo: "P1" }), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), ); assertEquals(res.status, 200); const body = await res.json(); @@ -132,12 +145,17 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + 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" }), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), ); assertEquals(res.status, 403); }, @@ -146,7 +164,8 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + name: + "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 404 when not found", async fn() { await truncateAll(); const res = await ueModuleHandler.GET!( @@ -162,7 +181,8 @@ Deno.test({ // --- PUT /ue-modules/:idModule/:idUE/:idPromo --- Deno.test({ - name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo updates only the targeted row (employee)", + 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" }]); @@ -175,7 +195,11 @@ Deno.test({ ]); 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" }), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), ); assertEquals(res.status, 200); const body = await res.json(); @@ -187,12 +211,17 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + 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" }), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), ); assertEquals(res.status, 403); }, @@ -201,7 +230,8 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + name: + "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 404 when not found", async fn() { await truncateAll(); const res = await ueModuleHandler.PUT!( @@ -217,7 +247,8 @@ Deno.test({ // --- DELETE /ue-modules/:idModule/:idUE/:idPromo --- Deno.test({ - name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo deletes only targeted row (employee)", + 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" }]); @@ -230,7 +261,11 @@ Deno.test({ ]); const res = await ueModuleHandler.DELETE!( makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), - makeEmployeeContext({ idModule: "M1", idUE: String(ue1.id), idPromo: "P1" }), + makeEmployeeContext({ + idModule: "M1", + idUE: String(ue1.id), + idPromo: "P1", + }), ); assertEquals(res.status, 204); // L'autre ligne doit toujours exister @@ -243,12 +278,17 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + 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" }), + makeContextWithAffiliation("student", { + idModule: "M1", + idUE: "1", + idPromo: "P1", + }), ); assertEquals(res.status, 403); }, @@ -257,7 +297,8 @@ Deno.test({ }); Deno.test({ - name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + name: + "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 404 when not found", async fn() { await truncateAll(); const res = await ueModuleHandler.DELETE!( diff --git a/tests/e2e/ues_test.ts b/tests/e2e/ues_test.ts index a57249b..1797f8d 100644 --- a/tests/e2e/ues_test.ts +++ b/tests/e2e/ues_test.ts @@ -17,7 +17,10 @@ Deno.test({ async fn() { await truncateAll(); await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]); - const res = await uesHandler.GET!(makeGetRequest("/ues"), makeEmployeeContext()); + const res = await uesHandler.GET!( + makeGetRequest("/ues"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); @@ -30,7 +33,10 @@ Deno.test({ name: "e2e ues: GET /ues returns empty when no UEs", async fn() { await truncateAll(); - const res = await uesHandler.GET!(makeGetRequest("/ues"), makeEmployeeContext()); + const res = await uesHandler.GET!( + makeGetRequest("/ues"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 0); @@ -113,7 +119,9 @@ Deno.test({ await truncateAll(); const [ue] = await seedUes([{ nom: "UE Biologie" }]); const res = await ueHandler.PUT!( - makeJsonRequest(`/ues/${ue.id}`, "PUT", { nom: "UE Biologie moléculaire" }), + makeJsonRequest(`/ues/${ue.id}`, "PUT", { + nom: "UE Biologie moléculaire", + }), makeEmployeeContext({ idUE: String(ue.id) }), ); assertEquals(res.status, 200); diff --git a/tests/e2e/users_test.ts b/tests/e2e/users_test.ts index 038ed2f..830aefa 100644 --- a/tests/e2e/users_test.ts +++ b/tests/e2e/users_test.ts @@ -24,7 +24,10 @@ Deno.test({ { id: "dupont.jean", nom: "Dupont", prenom: "Jean" }, { id: "martin.alice", nom: "Martin", prenom: "Alice" }, ]); - const res = await usersHandler.GET!(makeGetRequest("/users"), makeEmployeeContext()); + const res = await usersHandler.GET!( + makeGetRequest("/users"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 2); @@ -38,7 +41,10 @@ Deno.test({ name: "e2e users: GET /users returns empty when no users", async fn() { await truncateAll(); - const res = await usersHandler.GET!(makeGetRequest("/users"), makeEmployeeContext()); + const res = await usersHandler.GET!( + makeGetRequest("/users"), + makeEmployeeContext(), + ); assertEquals(res.status, 200); const body = await res.json(); assertEquals(body.length, 0); @@ -77,7 +83,11 @@ Deno.test({ async fn() { await truncateAll(); const res = await usersHandler.POST!( - makeJsonRequest("/users", "POST", { id: "new.user", nom: "New", prenom: "User" }), + makeJsonRequest("/users", "POST", { + id: "new.user", + nom: "New", + prenom: "User", + }), makeEmployeeContext(), ); assertEquals(res.status, 201); @@ -109,7 +119,11 @@ Deno.test({ 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" }), + makeJsonRequest("/users", "POST", { + id: "dupont.jean", + nom: "Doublon", + prenom: "X", + }), makeEmployeeContext(), ); assertEquals(res.status, 409); @@ -160,7 +174,11 @@ Deno.test({ 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 }), + makeJsonRequest("/users/thomas.eva", "PUT", { + nom: "Thomas-Modifié", + prenom: "Eva", + idRole: null, + }), makeEmployeeContext({ id: "thomas.eva" }), ); assertEquals(res.status, 200); @@ -176,7 +194,11 @@ Deno.test({ async fn() { await truncateAll(); const res = await userHandler.PUT!( - makeJsonRequest("/users/ghost.user", "PUT", { nom: "X", prenom: "Y", idRole: null }), + makeJsonRequest("/users/ghost.user", "PUT", { + nom: "X", + prenom: "Y", + idRole: null, + }), makeEmployeeContext({ id: "ghost.user" }), ); assertEquals(res.status, 404); diff --git a/tests/integration/ajustements_test.ts b/tests/integration/ajustements_test.ts index cd032a8..49e6fcd 100644 --- a/tests/integration/ajustements_test.ts +++ b/tests/integration/ajustements_test.ts @@ -17,7 +17,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "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); @@ -32,7 +36,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "P1" }]); + const [s] = await seedStudents([{ + nom: "Martin", + prenom: "Alice", + idPromo: "P1", + }]); const [ue] = await seedUes([{ nom: "UE Maths" }]); const [created] = await testDb @@ -45,7 +53,9 @@ Deno.test({ const row = await testDb .select() .from(ajustements) - .where(and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id))) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) .then((r) => r[0] ?? null); assertExists(row); assertEquals(row.valeur, 15.5); @@ -55,7 +65,8 @@ Deno.test({ }); Deno.test({ - name: "integration ajustements: get by composite key returns null when not found", + name: + "integration ajustements: get by composite key returns null when not found", async fn() { await truncateAll(); const row = await testDb @@ -74,11 +85,19 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Durand", prenom: "Claire", idPromo: "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 }) + testDb.insert(ajustements).values({ + numEtud: s.numEtud, + idUE: ue.id, + valeur: 13.0, + }) ); }, sanitizeResources: false, @@ -90,14 +109,20 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "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))) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) .returning(); assertEquals(updated.valeur, 18.0); }, @@ -110,15 +135,23 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); - const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "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))); + 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))) + .where( + and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)), + ) .then((r) => r[0] ?? null); assertEquals(row, null); }, diff --git a/tests/integration/enseignements_test.ts b/tests/integration/enseignements_test.ts index c48a312..40086a9 100644 --- a/tests/integration/enseignements_test.ts +++ b/tests/integration/enseignements_test.ts @@ -63,7 +63,8 @@ Deno.test({ }); Deno.test({ - name: "integration enseignements: get by composite key returns null when not found", + name: + "integration enseignements: get by composite key returns null when not found", async fn() { await truncateAll(); const row = await testDb @@ -90,9 +91,17 @@ Deno.test({ 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 seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); await assertRejects(() => - testDb.insert(enseignements).values({ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }) + testDb.insert(enseignements).values({ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }) ); }, sanitizeResources: false, @@ -106,7 +115,11 @@ Deno.test({ 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 seedEnseignements([{ + idProf: "prof.dupont", + idModule: "M1", + idPromo: "P1", + }]); await testDb .delete(enseignements) diff --git a/tests/integration/notes_test.ts b/tests/integration/notes_test.ts index bae19b3..b9018b9 100644 --- a/tests/integration/notes_test.ts +++ b/tests/integration/notes_test.ts @@ -17,7 +17,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "PROMO-2024" }]); - const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "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); @@ -32,10 +36,18 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "PROMO-2024" }]); - const [s] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "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(); + const [created] = await testDb.insert(notes).values({ + numEtud: s.numEtud, + idModule: "MOD102", + note: 12.0, + }).returning(); assertExists(created); assertEquals(created.note, 12.0); @@ -71,11 +83,19 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "PROMO-2024" }]); - const [s] = await seedStudents([{ nom: "Durand", prenom: "Claire", idPromo: "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 }) + testDb.insert(notes).values({ + numEtud: s.numEtud, + idModule: "MOD103", + note: 11.0, + }) ); }, sanitizeResources: false, @@ -87,7 +107,11 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "PROMO-2024" }]); - const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "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 }]); @@ -107,11 +131,17 @@ Deno.test({ async fn() { await truncateAll(); await seedPromotions([{ id: "PROMO-2024" }]); - const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "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"))); + await testDb.delete(notes).where( + and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105")), + ); const row = await testDb .select() .from(notes) diff --git a/tests/integration/ue_modules_test.ts b/tests/integration/ue_modules_test.ts index 9a996ad..9aaab2a 100644 --- a/tests/integration/ue_modules_test.ts +++ b/tests/integration/ue_modules_test.ts @@ -48,7 +48,13 @@ Deno.test({ const row = await testDb .select() .from(ueModules) - .where(and(eq(ueModules.idModule, "M1"), eq(ueModules.idUE, ue.id), eq(ueModules.idPromo, "P1"))) + .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); @@ -58,13 +64,20 @@ Deno.test({ }); Deno.test({ - name: "integration ue_modules: get by composite key returns null when not found", + 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"))) + .where( + and( + eq(ueModules.idModule, "GHOST"), + eq(ueModules.idUE, 99), + eq(ueModules.idPromo, "GHOST"), + ), + ) .then((r) => r[0] ?? null); assertEquals(row, null); }, @@ -79,9 +92,19 @@ Deno.test({ 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 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 }) + testDb.insert(ueModules).values({ + idModule: "M1", + idUE: ue.id, + idPromo: "P1", + coeff: 5.0, + }) ); }, sanitizeResources: false, @@ -95,12 +118,23 @@ Deno.test({ 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 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"))) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ) .returning(); assertEquals(updated.coeff, 6.0); }, @@ -115,15 +149,32 @@ Deno.test({ 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 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"))); + .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"))) + .where( + and( + eq(ueModules.idModule, "M1"), + eq(ueModules.idUE, ue.id), + eq(ueModules.idPromo, "P1"), + ), + ) .then((r) => r[0] ?? null); assertEquals(row, null); }, diff --git a/tests/integration/ues_test.ts b/tests/integration/ues_test.ts index 653fbef..790330a 100644 --- a/tests/integration/ues_test.ts +++ b/tests/integration/ues_test.ts @@ -21,12 +21,14 @@ 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(); + 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); + 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"); }, @@ -38,7 +40,9 @@ 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); + const row = await testDb.select().from(ues).where(eq(ues.id, 99999)).then(( + r, + ) => r[0] ?? null); assertEquals(row, null); }, sanitizeResources: false, @@ -50,7 +54,9 @@ Deno.test({ 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(); + 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, @@ -63,7 +69,9 @@ Deno.test({ 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); + const row = await testDb.select().from(ues).where(eq(ues.id, ue.id)).then(( + r, + ) => r[0] ?? null); assertEquals(row, null); }, sanitizeResources: false, @@ -80,4 +88,3 @@ Deno.test({ sanitizeResources: false, sanitizeOps: false, }); - diff --git a/tests/unit/ajustements_test.ts b/tests/unit/ajustements_test.ts index a2786c4..8820c23 100644 --- a/tests/unit/ajustements_test.ts +++ b/tests/unit/ajustements_test.ts @@ -32,7 +32,9 @@ 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 res = await fetch( + "http://localhost/api/ajustements?numEtud=21212006", + ); const data: Ajustement[] = await res.json(); assertEquals(data.length, 1); assertEquals(data[0].numEtud, 21212006); @@ -53,7 +55,9 @@ Deno.test("mock API: GET /ajustements?numEtud=NaN returns 400", async () => { 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 } }); + mockFetch({ + "/ajustements": { method: "POST", status: 201, body: newAjust }, + }); try { const res = await fetch("http://localhost/api/ajustements", { method: "POST", @@ -72,7 +76,9 @@ Deno.test("mock API: POST /ajustements creates ajustement (201) as employee", as 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" }); + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + }); assertEquals(res.status, 403); } finally { restoreFetch(); @@ -116,7 +122,12 @@ Deno.test("mock API: GET /ajustements/:numEtud/:idUE 403 for non-employee", asyn }); Deno.test("mock API: GET /ajustements/:numEtud/:idUE 404 when not found", async () => { - mockFetch({ "/ajustements/99999/9": { status: 404, body: { error: "Ajustement introuvable" } } }); + 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); @@ -127,7 +138,9 @@ Deno.test("mock API: GET /ajustements/:numEtud/:idUE 404 when not found", async 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 } }); + mockFetch({ + "/ajustements/21212006/1": { method: "PUT", status: 200, body: updated }, + }); try { const res = await fetch("http://localhost/api/ajustements/21212006/1", { method: "PUT", @@ -145,7 +158,9 @@ Deno.test("mock API: PUT /ajustements/:numEtud/:idUE updates valeur", async () = 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" }); + const res = await fetch("http://localhost/api/ajustements/21212006/1", { + method: "DELETE", + }); assertEquals(res.status, 204); } finally { restoreFetch(); @@ -156,34 +171,54 @@ Deno.test("mock API: DELETE /ajustements/:numEtud/:idUE returns 204", async () = 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); + 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); + 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 }); + 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 }); + 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, + 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); + 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 index 3182019..d1e3b04 100644 --- a/tests/unit/enseignements_test.ts +++ b/tests/unit/enseignements_test.ts @@ -22,8 +22,14 @@ Deno.test("enseignements: fixtures have correct shape", () => { // --- 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 } }); + 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", @@ -41,7 +47,9 @@ Deno.test("mock API: POST /enseignements creates enseignement (201) as employee" 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" }); + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + }); assertEquals(res.status, 403); } finally { restoreFetch(); @@ -64,13 +72,21 @@ Deno.test("mock API: POST /enseignements 400 on missing fields", async () => { Deno.test("mock API: POST /enseignements 409 on duplicate", async () => { mockFetch({ - "/enseignements": { method: "POST", status: 409, body: { error: "Cet enseignement existe déjà." } }, + "/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" }), + body: JSON.stringify({ + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), }); assertEquals(res.status, 409); const data = await res.json(); @@ -81,10 +97,16 @@ Deno.test("mock API: POST /enseignements 409 on duplicate", async () => { }); Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", async () => { - const ens: Enseignement = { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }; + 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"); + 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"); @@ -97,7 +119,9 @@ Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo returns ensei 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"); + const res = await fetch( + "http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", + ); assertEquals(res.status, 403); } finally { restoreFetch(); @@ -105,9 +129,16 @@ Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-e }); 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" } } }); + mockFetch({ + "/enseignements/ghost/GHOST/GHOST": { + status: 404, + body: { error: "Ressource introuvable" }, + }, + }); try { - const res = await fetch("http://localhost/api/enseignements/ghost/GHOST/GHOST"); + const res = await fetch( + "http://localhost/api/enseignements/ghost/GHOST/GHOST", + ); assertEquals(res.status, 404); } finally { restoreFetch(); @@ -115,11 +146,19 @@ Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 404 when not }); 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", { + 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(); @@ -127,11 +166,19 @@ Deno.test("mock API: DELETE /enseignements/:idProf/:idModule/:idPromo returns 20 }); 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", { + 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(); @@ -146,14 +193,21 @@ Deno.test("mock DB: find enseignement by composite key", () => { { 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"); + 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" }); + db.insert("enseignements", { + idProf: "prof.dupont", + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }); assertEquals(db.getTable("enseignements").length, 1); }); @@ -163,7 +217,10 @@ Deno.test("mock DB: delete enseignement", () => { { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, ]; const db = createMockDb({ tables: { enseignements: data } }); - db.deleteWhere("enseignements", (e) => e.idProf === "prof.dupont"); + db.deleteWhere( + "enseignements", + (e) => e.idProf === "prof.dupont", + ); assertEquals(db.getTable("enseignements").length, 1); }); @@ -174,6 +231,9 @@ Deno.test("mock DB: filter enseignements by idModule", () => { { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, ]; const db = createMockDb({ tables: { enseignements: data } }); - const rows = db.findMany("enseignements", (e) => e.idModule === "JIN702C"); + const rows = db.findMany( + "enseignements", + (e) => e.idModule === "JIN702C", + ); assertEquals(rows.length, 2); }); diff --git a/tests/unit/notes_test.ts b/tests/unit/notes_test.ts index f39d4ba..9e13794 100644 --- a/tests/unit/notes_test.ts +++ b/tests/unit/notes_test.ts @@ -113,7 +113,12 @@ Deno.test("mock API: GET /notes/:numEtud/:idModule returns note", async () => { }); Deno.test("mock API: GET /notes/:numEtud/:idModule 404 when not found", async () => { - mockFetch({ "/notes/99999/GHOST": { status: 404, body: { error: "Ressource introuvable" } } }); + 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); @@ -124,7 +129,9 @@ Deno.test("mock API: GET /notes/:numEtud/:idModule 404 when not found", async () 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 } }); + mockFetch({ + "/notes/21212006/JIN702C": { method: "PUT", status: 200, body: updated }, + }); try { const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { method: "PUT", @@ -142,7 +149,9 @@ Deno.test("mock API: PUT /notes/:numEtud/:idModule updates note", async () => { 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" }); + const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { + method: "DELETE", + }); assertEquals(res.status, 204); } finally { restoreFetch(); @@ -152,7 +161,9 @@ Deno.test("mock API: DELETE /notes/:numEtud/:idModule returns 204", async () => 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" }); + const res = await fetch("http://localhost/api/notes/99999/GHOST", { + method: "DELETE", + }); assertEquals(res.status, 404); } finally { restoreFetch(); @@ -163,7 +174,10 @@ Deno.test("mock API: DELETE /notes/:numEtud/:idModule 404 when not found", async 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"); + const n = db.findOne( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + ); assertExists(n); assertEquals(n.note, 15.5); }); @@ -176,21 +190,35 @@ Deno.test("mock DB: filter notes by numEtud", () => { Deno.test("mock DB: insert note", () => { const db = createMockDb({ tables: { notes: [...notes] } }); - db.insert("notes", { note: 10.0, numEtud: 21212006, idModule: "JIN704C" }); + 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 }); + 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, + 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"); + db.deleteWhere( + "notes", + (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", + ); assertEquals(db.getTable("notes").length, 3); }); diff --git a/tests/unit/ue_modules_test.ts b/tests/unit/ue_modules_test.ts index 8037998..7b2761d 100644 --- a/tests/unit/ue_modules_test.ts +++ b/tests/unit/ue_modules_test.ts @@ -33,7 +33,9 @@ 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 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); @@ -56,8 +58,15 @@ Deno.test("mock API: GET /ue-modules?idUE filters by UE", async () => { }); 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 } }); + 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", @@ -90,7 +99,9 @@ Deno.test("mock API: POST /ue-modules 400 on missing fields", async () => { 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"); + 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); @@ -102,7 +113,9 @@ Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo returns associatio 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"); + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + ); assertEquals(res.status, 403); } finally { restoreFetch(); @@ -111,13 +124,22 @@ Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employ 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", { + mockFetch({ + "/ue-modules/JIN702C/1/4AFISE25": { method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ coeff: 5.0 }), - }); + 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); @@ -127,9 +149,14 @@ Deno.test("mock API: PUT /ue-modules/:idModule/:idUE/:idPromo updates coeff", as }); Deno.test("mock API: DELETE /ue-modules/:idModule/:idUE/:idPromo returns 204", async () => { - mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": { method: "DELETE", status: 204 } }); + 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" }); + const res = await fetch( + "http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", + { method: "DELETE" }, + ); assertEquals(res.status, 204); } finally { restoreFetch(); @@ -140,34 +167,56 @@ Deno.test("mock API: DELETE /ue-modules/:idModule/:idUE/:idPromo returns 204", a 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"); + 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"); + 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 }); + 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 }); + 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, + 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); + 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 index 5d36a95..f823f7d 100644 --- a/tests/unit/ues_test.ts +++ b/tests/unit/ues_test.ts @@ -42,7 +42,9 @@ Deno.test("mock API: GET /ues/:id returns one UE", async () => { }); Deno.test("mock API: GET /ues/:id 404 when not found", async () => { - mockFetch({ "/ues/99": { status: 404, body: { error: "Ressource introuvable" } } }); + mockFetch({ + "/ues/99": { status: 404, body: { error: "Ressource introuvable" } }, + }); try { const res = await fetch("http://localhost/api/ues/99"); assertEquals(res.status, 404); @@ -125,7 +127,9 @@ Deno.test("mock API: DELETE /ues/:id returns 204", async () => { 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" }); + const res = await fetch("http://localhost/api/ues/99", { + method: "DELETE", + }); assertEquals(res.status, 404); } finally { restoreFetch(); -- 2.52.0 From 34b7ac023177387c3754c016bda430707160ee3d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 22:29:10 +0200 Subject: [PATCH 080/103] docs: update CLAUDE.md to reflect completed API layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark all implemented endpoints as ✅, document the 3-level test architecture, and clarify that UI pages are the next priority. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 147 +++++++++++++++++++++++++++++------------------------- 1 file changed, 80 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe5c70d..435d43f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ role-based administration. ### Current Status -🚧 **In Progress** - Application is far from complete. The schema below is the -**final/definitive schema** that should guide all development. +🚧 **In Progress** - API layer largely complete, UI pages not yet built. +The schema below is the **final/definitive schema** that guides all development. --- @@ -133,15 +133,11 @@ erDiagram AJUSTEMENT }o--|| UE : "dans" ``` -### Current Schema (Incomplete) +### Current Schema -The current Drizzle ORM schema in `/databases/schema.ts` only implements: - -- `promotions` -- `students` -- `mobility` - -**Migration needed**: Update schema to match the final ER diagram above. +The Drizzle ORM schema in `/databases/schema.ts` implements all tables: +`roles`, `permissions`, `rolePermissions`, `users`, `promotions`, `students`, +`modules`, `enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. --- @@ -186,71 +182,73 @@ The current Drizzle ORM schema in `/databases/schema.ts` only implements: ### API Endpoints +Legend: ✅ implemented & tested | 📋 not yet implemented + **Students API** -- 📋 GET `/students` (#7) -- 📋 POST `/students` (#8) -- 📋 POST `/students/import-csv` (#9) -- 📋 GET `/students/{numEtud}` (#10) -- 📋 PUT `/students/{numEtud}` (#11) -- 📋 DELETE `/students/{numEtud}` (#12) -- 📋 GET `/promotions` (#13) -- 📋 POST `/promotions` (#14) -- 📋 GET `/promotions/{idPromo}` (#15) -- 📋 PUT `/promotions/{idPromo}` (#16) -- 📋 DELETE `/promotions/{idPromo}` (#17) +- ✅ GET `/students` (#7) +- ✅ POST `/students` (#8) +- ✅ POST `/students/import-csv` (#9) +- ✅ GET `/students/{numEtud}` (#10) +- ✅ PUT `/students/{numEtud}` (#11) +- ✅ DELETE `/students/{numEtud}` (#12) +- ✅ GET `/promotions` (#13) +- ✅ POST `/promotions` (#14) +- ✅ GET `/promotions/{idPromo}` (#15) +- ✅ PUT `/promotions/{idPromo}` (#16) +- ✅ DELETE `/promotions/{idPromo}` (#17) **Administration API - Modules & Enseignements** -- 📋 GET `/modules` (#23) -- 📋 POST `/modules` (#24) -- 📋 GET `/modules/{idModule}` (#25) -- 📋 PUT `/modules/{idModule}` (#26) -- 📋 DELETE `/modules/{idModule}` (#27) -- 📋 POST `/enseignements` (#29) -- 📋 GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) -- 📋 DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) +- ✅ GET `/modules` (#23) +- ✅ POST `/modules` (#24) +- ✅ GET `/modules/{idModule}` (#25) +- ✅ PUT `/modules/{idModule}` (#26) +- ✅ DELETE `/modules/{idModule}` (#27) +- ✅ POST `/enseignements` (#29) +- ✅ GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) +- ✅ DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) **Notes API - UEs & UE-Modules** -- 📋 GET `/ues` (#32) -- 📋 POST `/ues` (#33) -- 📋 GET `/ues/{idUE}` (#34) -- 📋 PUT `/ues/{idUE}` (#35) -- 📋 DELETE `/ues/{idUE}` (#36) -- 📋 GET `/ue-modules` (#37) -- 📋 POST `/ue-modules` (#38) -- 📋 GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) -- 📋 PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) -- 📋 DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) +- ✅ GET `/ues` (#32) +- ✅ POST `/ues` (#33) +- ✅ GET `/ues/{idUE}` (#34) +- ✅ PUT `/ues/{idUE}` (#35) +- ✅ DELETE `/ues/{idUE}` (#36) +- ✅ GET `/ue-modules` (#37) +- ✅ POST `/ue-modules` (#38) +- ✅ GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) +- ✅ PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) +- ✅ DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) **Notes API - Notes & Ajustements** -- 📋 GET `/notes` (#42) -- 📋 POST `/notes` (#43) +- ✅ GET `/notes` (#42) +- ✅ POST `/notes` (#43) - 📋 POST `/notes/import-xlsx` (#44) -- 📋 GET `/notes/{numEtud}/{idModule}` (#45) -- 📋 PUT `/notes/{numEtud}/{idModule}` (#46) -- 📋 DELETE `/notes/{numEtud}/{idModule}` (#47) -- 📋 GET `/ajustements` (#48) -- 📋 POST `/ajustements` (#49) -- 📋 GET `/ajustements/{numEtud}/{idUE}` (#50) -- 📋 PUT `/ajustements/{numEtud}/{idUE}` (#51) -- 📋 DELETE `/ajustements/{numEtud}/{idUE}` (#52) +- ✅ GET `/notes/{numEtud}/{idModule}` (#45) +- ✅ PUT `/notes/{numEtud}/{idModule}` (#46) +- ✅ DELETE `/notes/{numEtud}/{idModule}` (#47) +- ✅ GET `/ajustements` (#48) +- ✅ POST `/ajustements` (#49) +- ✅ GET `/ajustements/{numEtud}/{idUE}` (#50) +- ✅ PUT `/ajustements/{numEtud}/{idUE}` (#51) +- ✅ DELETE `/ajustements/{numEtud}/{idUE}` (#52) **Administration API - Users, Roles & Permissions** -- 📋 GET `/users` (#60) -- 📋 POST `/users` (#61) -- 📋 GET `/users/{id}` (#62) -- 📋 PUT `/users/{id}` (#63) -- 📋 DELETE `/users/{id}` (#64) -- 📋 GET `/roles` (#65) -- 📋 POST `/roles` (#66) -- 📋 GET `/roles/{idRole}` (#67) -- 📋 PUT `/roles/{idRole}` (#68) -- 📋 DELETE `/roles/{idRole}` (#69) -- 📋 GET `/permissions` (#70) +- ✅ GET `/users` (#60) +- ✅ POST `/users` (#61) +- ✅ GET `/users/{id}` (#62) +- ✅ PUT `/users/{id}` (#63) +- ✅ DELETE `/users/{id}` (#64) +- ✅ GET `/roles` (#65) +- ✅ POST `/roles` (#66) +- ✅ GET `/roles/{idRole}` (#67) +- ✅ PUT `/roles/{idRole}` (#68) +- ✅ DELETE `/roles/{idRole}` (#69) +- ✅ GET `/permissions` (#70) --- @@ -298,10 +296,24 @@ deno task check ### Testing -- Write unit tests for business logic -- Integration tests for API endpoints -- E2E tests with HappyDOM for UI interactions -- Mock database with provided helpers +3-level architecture — all 149 tests pass: + +- **Unit** (`tests/unit/`) — pure logic with mock DB + mock API, no real DB +- **Integration** (`tests/integration/`) — Drizzle ORM direct on real DB +- **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) + +Helpers in `tests/helpers/`: +- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, `makeJsonRequest`…) +- `db_integration.ts` — seed functions + `truncateAll()` for test isolation +- `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests + +```bash +deno task test # run all tests +deno task test:coverage # coverage report (terminal) +deno task test:coverage:html # coverage report (HTML → coverage/html/index.html) +nix run nixpkgs#act -- -j unit --no-cache-server # unit tests via GitHub Actions +nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via GitHub Actions +``` --- @@ -327,12 +339,13 @@ deno task check ## 💡 Important Notes -1. **Current Limitation**: The database schema in `/databases/schema.ts` does - NOT match the final ER diagram. This is a priority migration task. -2. **Design System**: Follow the Figma prototype for all UI work. +1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints are implemented. +2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. 3. **Module Pattern**: Each module should follow the same pattern: routes, API endpoints, components, and tests. 4. **Permissions**: All admin operations should respect the ROLE_PERMISSION system. 5. **Fresh Conventions**: Routes use Fresh's file-based routing convention (e.g., `routes/path/index.tsx`). +6. **Drizzle `.where()` pitfall**: Always wrap multiple conditions with `and()`. + `.where(eq(a), eq(b))` silently ignores the second argument. -- 2.52.0 From 5ba8b8cb68356ab5ff5141c008043c63b02c18fd Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 22:54:10 +0200 Subject: [PATCH 081/103] feat(ui): implement full UI layer for all modules Add interactive island components and server partials for notes, students, and admin modules, following the Figma prototype design. - static/styles/ui.css: shared component library (buttons, tables, chips, cards, filters, tabs, form inputs) - notes: NotesView (student grade view with UE cards, promo tabs, weighted averages), AdminConsultNotes, AdminUEs islands + partials - students: ConsultStudents (list/filter/delete), AdminPromotions (CRUD) islands + partials - admin: AdminModules, AdminUsers, AdminRoles islands + partials - All partials use State type with unknown cast for session access Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 17 +- .../(apps)/admin/(_islands)/AdminModules.tsx | 204 +++++++++ routes/(apps)/admin/(_islands)/AdminRoles.tsx | 225 ++++++++++ routes/(apps)/admin/(_islands)/AdminUsers.tsx | 201 +++++++++ routes/(apps)/admin/(_props)/props.ts | 7 +- routes/(apps)/admin/partials/index.tsx | 37 +- routes/(apps)/admin/partials/modules.tsx | 18 + routes/(apps)/admin/partials/roles.tsx | 18 + routes/(apps)/admin/partials/users.tsx | 18 + .../notes/(_islands)/AdminConsultNotes.tsx | 145 +++++++ routes/(apps)/notes/(_islands)/AdminUEs.tsx | 124 ++++++ routes/(apps)/notes/(_islands)/NotesView.tsx | 223 ++++++++++ routes/(apps)/notes/(_props)/props.ts | 9 +- .../(apps)/notes/partials/(admin)/courses.tsx | 7 +- routes/(apps)/notes/partials/(admin)/ues.tsx | 18 + routes/(apps)/notes/partials/index.tsx | 48 ++- routes/(apps)/notes/partials/notes.tsx | 33 +- .../students/(_islands)/AdminPromotions.tsx | 143 +++++++ .../students/(_islands)/ConsultStudents.tsx | 171 ++++++-- routes/(apps)/students/(_props)/props.ts | 9 +- .../students/partials/(admin)/consult.tsx | 7 +- .../students/partials/(admin)/promotions.tsx | 18 + routes/(apps)/students/partials/index.tsx | 42 +- routes/_app.tsx | 1 + static/styles/ui.css | 393 ++++++++++++++++++ 25 files changed, 2059 insertions(+), 77 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/AdminModules.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminRoles.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminUsers.tsx create mode 100644 routes/(apps)/admin/partials/modules.tsx create mode 100644 routes/(apps)/admin/partials/roles.tsx create mode 100644 routes/(apps)/admin/partials/users.tsx create mode 100644 routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx create mode 100644 routes/(apps)/notes/(_islands)/AdminUEs.tsx create mode 100644 routes/(apps)/notes/(_islands)/NotesView.tsx create mode 100644 routes/(apps)/notes/partials/(admin)/ues.tsx create mode 100644 routes/(apps)/students/(_islands)/AdminPromotions.tsx create mode 100644 routes/(apps)/students/partials/(admin)/promotions.tsx create mode 100644 static/styles/ui.css diff --git a/CLAUDE.md b/CLAUDE.md index 435d43f..f3f37b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ role-based administration. ### Current Status -🚧 **In Progress** - API layer largely complete, UI pages not yet built. -The schema below is the **final/definitive schema** that guides all development. +🚧 **In Progress** - API layer largely complete, UI pages not yet built. The +schema below is the **final/definitive schema** that guides all development. --- @@ -135,9 +135,9 @@ erDiagram ### Current Schema -The Drizzle ORM schema in `/databases/schema.ts` implements all tables: -`roles`, `permissions`, `rolePermissions`, `users`, `promotions`, `students`, -`modules`, `enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. +The Drizzle ORM schema in `/databases/schema.ts` implements all tables: `roles`, +`permissions`, `rolePermissions`, `users`, `promotions`, `students`, `modules`, +`enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. --- @@ -303,7 +303,9 @@ deno task check - **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) Helpers in `tests/helpers/`: -- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, `makeJsonRequest`…) + +- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, + `makeJsonRequest`…) - `db_integration.ts` — seed functions + `truncateAll()` for test isolation - `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests @@ -339,7 +341,8 @@ nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via ## 💡 Important Notes -1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints are implemented. +1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints + are implemented. 2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. 3. **Module Pattern**: Each module should follow the same pattern: routes, API endpoints, components, and tests. diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx new file mode 100644 index 0000000..df0af41 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; + +export default function AdminModules() { + const [modules, setModules] = 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 [editId, setEditId] = useState(null); + const [editNom, setEditNom] = useState(""); + + async function load() { + try { + const res = await fetch("/admin/api/modules"); + if (!res.ok) throw new Error("Impossible de charger les modules"); + setModules(await res.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 saveEdit(id: string) { + try { + const res = await fetch(`/admin/api/modules/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: editNom.trim() }), + }); + if (!res.ok) throw new Error("Modification échouée"); + setEditId(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function deleteModule(id: string) { + if (!confirm(`Supprimer le module ${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"); + } + } + + return ( +
+

Gestion des Modules

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {modules.length === 0 + ? ( + + + + ) + : modules.map((m) => ( + + + + + + ))} + +
IdentifiantNomActions
+ Aucun module enregistré +
{m.id} + {editId === m.id + ? ( + + setEditNom( + (e.target as HTMLInputElement).value, + )} + style="min-width: 0; width: 100%" + /> + ) + : m.nom} + +
+ {editId === m.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx new file mode 100644 index 0000000..9e97fad --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from "preact/hooks"; + +type Role = { id: number; nom: string }; +type Permission = { id: string; nom: string }; + +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); + const [editId, setEditId] = useState(null); + const [editNom, setEditNom] = useState(""); + + 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 saveEdit(id: number) { + try { + const res = await fetch(`/admin/api/roles/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: editNom.trim() }), + }); + if (!res.ok) throw new Error("Modification échouée"); + setEditId(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + 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"); + } + } + + return ( +
+

Gestion des Rôles

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createRole()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {roles.length === 0 + ? ( + + + + ) + : roles.map((r) => ( + + + + + + ))} + +
IDNomActions
+ Aucun rôle enregistré +
{r.id} + {editId === r.id + ? ( + + setEditNom( + (e.target as HTMLInputElement).value, + )} + style="min-width: 0; width: 100%" + /> + ) + : r.nom} + +
+ {editId === r.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} + + {permissions.length > 0 && ( +
+

+ Permissions disponibles +

+
+ + + + + + + + + {permissions.map((p) => ( + + + + + ))} + +
IDNom
{p.id}{p.nom}
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx new file mode 100644 index 0000000..eee86f9 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from "preact/hooks"; + +type User = { id: string; nom: string; prenom: string; idRole: number | null }; +type Role = { id: number; nom: string }; + +export default function AdminUsers() { + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + 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(""); + + 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(""); + 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) => + !filterNom || + `${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes( + filterNom.toLowerCase(), + ) + ); + + return ( +
+

Gestion des Utilisateurs

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + setNewPrenom((e.target as HTMLInputElement).value)} + /> + + +
+ +
+ setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((u) => ( + + + + + + + + ))} + +
LoginNomPrénomRôleActions
+ Aucun utilisateur trouvé +
{u.id}{u.nom}{u.prenom} + {u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"} + +
+ + ✏ + + +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 3ae55a1..a95fb54 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -4,9 +4,12 @@ const properties: AppProperties = { name: "Admin", icon: "school", pages: { - index: "Homepage", + index: "Accueil", + modules: "Modules", + users: "Utilisateurs", + roles: "Rôles", }, - adminOnly: [], + adminOnly: ["modules", "users", "roles"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx index 4e0c915..bedfc1e 100644 --- a/routes/(apps)/admin/partials/index.tsx +++ b/routes/(apps)/admin/partials/index.tsx @@ -3,10 +3,41 @@ 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"; -export function Index(_request: Request, _context: FreshContext) { - return

Welcome to Admin.

; +// 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(); diff --git a/routes/(apps)/admin/partials/modules.tsx b/routes/(apps)/admin/partials/modules.tsx new file mode 100644 index 0000000..a36640d --- /dev/null +++ b/routes/(apps)/admin/partials/modules.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Modules); diff --git a/routes/(apps)/admin/partials/roles.tsx b/routes/(apps)/admin/partials/roles.tsx new file mode 100644 index 0000000..b40aeb0 --- /dev/null +++ b/routes/(apps)/admin/partials/roles.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Roles); diff --git a/routes/(apps)/admin/partials/users.tsx b/routes/(apps)/admin/partials/users.tsx new file mode 100644 index 0000000..837d515 --- /dev/null +++ b/routes/(apps)/admin/partials/users.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Users); diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx new file mode 100644 index 0000000..9ae2c94 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -0,0 +1,145 @@ +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)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx new file mode 100644 index 0000000..d698c34 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "preact/hooks"; + +type UE = { id: number; nom: string }; + +export default function AdminUEs() { + const [ues, setUes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + + async function load() { + try { + const res = await fetch("/notes/api/ues"); + if (!res.ok) throw new Error("Impossible de charger les UEs"); + setUes(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUE() { + if (!newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/notes/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newNom.trim() }), + }); + if (!res.ok) throw new Error("Création échouée"); + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteUE(id: number) { + if (!confirm("Supprimer cette UE ?")) return; + try { + const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des UEs

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {ues.length === 0 + ? ( + + + + ) + : ues.map((ue) => ( + + + + + + ))} + +
IDNomAction
+ Aucune UE enregistrée +
{ue.id}{ue.nom} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx new file mode 100644 index 0000000..fd77b87 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "preact/hooks"; + +type Note = { numEtud: number; idModule: string; note: number }; +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 }; + +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"; +} + +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("/admin/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); + + // Derive promos from UE-modules for this student's notes + 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 étudiant n'est associé à votre compte. +

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

Chargement…

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

{error}

+
+ ); + } + + // Filter UE-modules by active promo + const filteredUeModules = activePromo + ? ueModules.filter((um) => um.idPromo === activePromo) + : ueModules; + + // Group UE-modules by UE + 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.note]), + ); + const ajMap = Object.fromEntries( + ajustements.map((a) => [a.idUE, a.valeur]), + ); + + return ( +
+ {promos.length > 1 && ( +
+ {promos.map((p) => ( + + ))} +
+ )} + + {ueIds.length === 0 && ( +

Aucune note disponible pour cette période.

+ )} + + {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 note = noteMap[um.idModule]; + if (note !== undefined) { + weightedSum += note * um.coeff; + coveredCoeff += um.coeff; + } + }); + + const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null; + const ajustement = ajMap[ueId] ?? null; + const finalAvg = avg !== null && ajustement !== null + ? avg + ajustement + : avg; + + return ( +
+
+

UE : {ue.nom}

+ {finalAvg !== null && ( +

+ Moyenne : {finalAvg.toFixed(2)}/20 + {ajustement !== null && ajustement !== 0 && ( + + {" "} + (ajustement : {ajustement > 0 ? "+" : ""} + {ajustement}) + + )} +

+ )} + {finalAvg === null && ( +

Notes non disponibles

+ )} +
+ + {ueModsForUE.map((um) => { + const mod = moduleMap[um.idModule]; + const note = noteMap[um.idModule] ?? null; + return ( +
+ + {mod ? mod.id : um.idModule} —{" "} + {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) + + + {note !== null ? `${note}/20` : "—"} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 36b0f28..fb7f11b 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "PolyNotes", icon: "school", pages: { - index: "Homepage", - notes: "Notes", - courses: "Courses management", + index: "Accueil", + notes: "Mes notes", + courses: "Consulter", + ues: "UEs", }, - adminOnly: ["courses", "students"], + adminOnly: ["courses", "ues"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 3ac215d..0ec8ebe 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -3,11 +3,12 @@ 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 const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/notes/partials/(admin)/ues.tsx new file mode 100644 index 0000000..2d6b0e9 --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/ues.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(UEs); 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..188a05e 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -1,13 +1,36 @@ +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 { 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: { sn: string; givenName: string } }) + .session; + const { sn, givenName } = session; + + let numEtud: number | null = null; + try { + const student = await db + .select() + .from(students) + .where(and(eq(students.nom, sn), eq(students.prenom, givenName))) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } catch { + // DB lookup failed — island will show fallback message + } + + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx new file mode 100644 index 0000000..5461d9e --- /dev/null +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "preact/hooks"; + +type Promotion = { id: string; annee: string | null }; + +export default function AdminPromotions() { + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newAnnee, setNewAnnee] = useState(""); + const [creating, setCreating] = useState(false); + + async function load() { + try { + const res = await fetch("/students/api/promotions"); + if (!res.ok) throw new Error("Impossible de charger les promotions"); + setPromos(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createPromo() { + if (!newId.trim()) return; + setCreating(true); + try { + const res = await fetch("/students/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idPromo: newId.trim(), + annee: newAnnee.trim() || null, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewAnnee(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deletePromo(id: string) { + if (!confirm(`Supprimer la promotion ${id} ?`)) return; + try { + const res = await fetch( + `/students/api/promotions/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des Promotions

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + /> + setNewAnnee((e.target as HTMLInputElement).value)} + style="min-width: 14rem" + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {promos.length === 0 + ? ( + + + + ) + : promos.map((p) => ( + + + + + + ))} + +
IdentifiantAnnéeAction
+ Aucune promotion enregistrée +
{p.id}{p.annee ?? "—"} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index f67036b..031bbe9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,45 +1,150 @@ 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(""); + + 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(); + } 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; + }); + 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)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
N° étud.NomPrénomPromoActions
+ Aucun élève trouvé +
{s.numEtud}{s.nom}{s.prenom}{s.idPromo} +
+ + ✏ + + +
+
+
+ )} +
); } diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 13bafe9..5483732 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", + promotions: "Promotions", + upload: "Import xlsx", }, - adminOnly: ["upload", "consult"], + adminOnly: ["consult", "promotions", "upload"], hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index b685c5c..4c81c71 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -8,12 +8,7 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { - return ( - <> -

Consult students

- - - ); + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/students/partials/(admin)/promotions.tsx new file mode 100644 index 0000000..003f993 --- /dev/null +++ b/routes/(apps)/students/partials/(admin)/promotions.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Promotions); 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..8162820 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -28,6 +28,7 @@ export default async function App( +
diff --git a/static/styles/ui.css b/static/styles/ui.css new file mode 100644 index 0000000..e56daa2 --- /dev/null +++ b/static/styles/ui.css @@ -0,0 +1,393 @@ +/* ui.css — Shared UI components for PolyMPR app pages */ + +/* ------------------------------------------------------- + Page layout +------------------------------------------------------- */ + +.page-content { + padding: 1.5rem; + max-width: 960px; +} + +.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; +} + +.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: 12rem; +} + +.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; +} -- 2.52.0 From fcc9547a30b2ebc8fa3400f6533df868afacb800 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 23:01:59 +0200 Subject: [PATCH 082/103] feat(dev): add compose files and dev-login bypass route - compose.prod.yml: production stack with registry image, healthcheck, migration service - compose.test.yml: local test stack with source mount and LOCAL=true - routes/dev-login.ts: fake admin JWT login, only active when LOCAL=true - routes/_middleware.ts: expose /dev-login as public route Co-Authored-By: Claude Sonnet 4.6 --- compose.prod.yml | 36 +++++++++++++++++++++++++++++++ compose.test.yml | 49 +++++++++++++++++++++++++++++++++++++++++++ routes/_middleware.ts | 1 + routes/dev-login.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 compose.prod.yml create mode 100644 compose.test.yml create mode 100644 routes/dev-login.ts diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..6fcc5bc --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,36 @@ +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 + command: 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 + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + db_data: diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..18478d8 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,49 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: testpass + POSTGRES_USER: postgres + POSTGRES_DB: polympr_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: denoland/deno:alpine + working_dir: /app + volumes: + - .:/app + command: task migrate + environment: + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + LOCAL: "true" + depends_on: + db: + condition: service_healthy + + app: + image: denoland/deno:alpine + working_dir: /app + volumes: + - .:/app + 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 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/dev-login.ts b/routes/dev-login.ts new file mode 100644 index 0000000..b50898e --- /dev/null +++ b/routes/dev-login.ts @@ -0,0 +1,48 @@ +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"; + +const FAKE_ADMIN: CasContent = { + 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 now = Math.floor(Date.now() / 1000); + const payload: LoginJWT = { + iss: "PolyMPR", + iat: now, + exp: now + 0xe10, + aud: "PolyMPR", + user: FAKE_ADMIN, + }; + + const token = await createJwt(payload, getKey(FAKE_ADMIN.uid)); + const headers = new Headers(); + setCookie(headers, { name: "sessionToken", value: token }); + headers.set("Location", "/apps"); + + return new Response(null, { status: 302, headers }); + }, +}; -- 2.52.0 From 56019ad372078d9744ac55c22ab17b75c82718e2 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 00:04:28 +0200 Subject: [PATCH 083/103] fix: fixed test ci --- compose.test.yml | 35 ++++++--------- databases/docker-init.sh | 10 +++++ fresh.gen.ts | 94 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 21 deletions(-) create mode 100755 databases/docker-init.sh diff --git a/compose.test.yml b/compose.test.yml index 18478d8..37b8e04 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -6,21 +6,29 @@ services: POSTGRES_PASSWORD: testpass POSTGRES_USER: postgres POSTGRES_DB: polympr_test + volumes: + # Init script strips drizzle-kit markers and applies migrations on first start + - ./databases/docker-init.sh:/docker-entrypoint-initdb.d/01-migrate.sh:ro + - ./databases/migrations:/migrations:ro + - db_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 10 - migrate: + app: image: denoland/deno:alpine working_dir: /app volumes: - .:/app - command: task migrate + - deno_cache:/deno-dir + command: run -A --unstable-ffi main.ts + ports: + - "4430:443" environment: POSTGRES_HOST: db - POSTGRES_PORT: 5432 + POSTGRES_PORT: "5432" POSTGRES_USER: postgres POSTGRES_PASS: testpass POSTGRES_DB: polympr_test @@ -29,21 +37,6 @@ services: db: condition: service_healthy - app: - image: denoland/deno:alpine - working_dir: /app - volumes: - - .:/app - 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/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/fresh.gen.ts b/fresh.gen.ts index eeb5302..2309f78 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,18 +4,47 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; +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_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_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_roles from "./routes/(apps)/admin/partials/roles.tsx"; +import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.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_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_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_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; +import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; +import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; 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_ues from "./routes/(apps)/notes/partials/(admin)/ues.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_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_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_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; @@ -24,14 +53,22 @@ 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_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx"; +import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; +import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.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_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; +import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; +import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; +import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.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,6 +78,25 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./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/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/partials/index.tsx": $_apps_admin_partials_index, + "./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules, + "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, + "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/mobility/api/insert_mobility.ts": $_apps_mobility_api_insert_mobility, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, @@ -50,15 +106,38 @@ const manifest = { $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./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/notes.ts": $_apps_notes_api_notes, + "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": + $_apps_notes_api_notes_numEtud_idModule_, + "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, + "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts": + $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, + "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, + "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, "./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)/ues.tsx": + $_apps_notes_partials_admin_ues, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, + "./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/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, + "./routes/(apps)/students/partials/(admin)/promotions.tsx": + $_apps_students_partials_admin_promotions, "./routes/(apps)/students/partials/(admin)/upload.tsx": $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": @@ -69,6 +148,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 +156,26 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, + "./routes/(apps)/admin/(_islands)/AdminModules.tsx": + $_apps_admin_islands_AdminModules, + "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": + $_apps_admin_islands_AdminRoles, + "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": + $_apps_admin_islands_AdminUsers, "./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)/notes/(_islands)/AdminConsultNotes.tsx": + $_apps_notes_islands_AdminConsultNotes, + "./routes/(apps)/notes/(_islands)/AdminUEs.tsx": + $_apps_notes_islands_AdminUEs, + "./routes/(apps)/notes/(_islands)/NotesView.tsx": + $_apps_notes_islands_NotesView, + "./routes/(apps)/students/(_islands)/AdminPromotions.tsx": + $_apps_students_islands_AdminPromotions, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": -- 2.52.0 From 733259e317d3ae5da3dc7ca5d7dde2b384d97c47 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 11:21:32 +0200 Subject: [PATCH 084/103] feat : fixed some page not being as described in the figma --- fresh.gen.ts | 15 + .../admin/(_islands)/AdminEnseignements.tsx | 292 +++++++++++++ .../admin/(_islands)/AdminPermissions.tsx | 107 +++++ routes/(apps)/admin/(_islands)/AdminRoles.tsx | 301 ++++++++----- routes/(apps)/admin/(_props)/props.ts | 6 +- routes/(apps)/admin/api/enseignements.ts | 16 + .../(apps)/admin/partials/enseignements.tsx | 18 + routes/(apps)/admin/partials/permissions.tsx | 18 + routes/(apps)/notes/(_islands)/AdminUEs.tsx | 353 ++++++++++++--- .../students/(_islands)/AdminPromotions.tsx | 198 ++++++--- .../students/(_islands)/EditStudents.tsx | 247 +++++++++++ routes/(apps)/students/edit/[numEtud].tsx | 12 + static/styles/ui.css | 409 ++++++++++++++++++ 13 files changed, 1757 insertions(+), 235 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/AdminEnseignements.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminPermissions.tsx create mode 100644 routes/(apps)/admin/partials/enseignements.tsx create mode 100644 routes/(apps)/admin/partials/permissions.tsx create mode 100644 routes/(apps)/students/edit/[numEtud].tsx diff --git a/fresh.gen.ts b/fresh.gen.ts index 2309f78..a4a95f9 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,8 +15,10 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles 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_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.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_roles from "./routes/(apps)/admin/partials/roles.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; @@ -42,6 +44,7 @@ import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/studen 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_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; @@ -59,7 +62,9 @@ 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_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_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; @@ -93,8 +98,12 @@ const manifest = { "./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/partials/enseignements.tsx": + $_apps_admin_partials_enseignements, "./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/roles.tsx": $_apps_admin_partials_roles, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/mobility/api/insert_mobility.ts": @@ -133,6 +142,8 @@ const manifest = { $_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, @@ -156,8 +167,12 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, + "./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)/AdminRoles.tsx": $_apps_admin_islands_AdminRoles, "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx new file mode 100644 index 0000000..7b158d2 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -0,0 +1,292 @@ +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 → Module / Promo

+ + {error &&

{error}

} + +
+ + + + setFilterEnseignant((e.target as HTMLInputElement).value)} + /> + + +
+ + {showAdd && ( +
+ {addError && ( + + {addError} + + )} + + + setAddProf((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + + +
+ )} + + {loading + ?

Chargement…

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

+ Un même module 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)/AdminPermissions.tsx b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx new file mode 100644 index 0000000..57c600b --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "preact/hooks"; + +type Perm = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; + +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)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index 9e97fad..448e334 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -1,17 +1,23 @@ import { useEffect, useState } from "preact/hooks"; -type Role = { id: number; nom: string }; -type Permission = { id: string; nom: string }; +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 [permissions, setPermissions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newNom, setNewNom] = useState(""); const [creating, setCreating] = useState(false); - const [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + + // 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 { @@ -55,21 +61,6 @@ export default function AdminRoles() { } } - async function saveEdit(id: number) { - try { - const res = await fetch(`/admin/api/roles/${id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: editNom.trim() }), - }); - if (!res.ok) throw new Error("Modification échouée"); - setEditId(null); - await load(); - } catch (e) { - setError(e instanceof Error ? e.message : "Erreur"); - } - } - async function deleteRole(id: number) { if (!confirm("Supprimer ce rôle ?")) return; try { @@ -81,19 +72,143 @@ export default function AdminRoles() { } } + 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 ( + + ); + })} +
+
+ ); + } + + // ---- 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" />
@@ -112,8 +227,9 @@ export default function AdminRoles() { - - + + + @@ -121,105 +237,60 @@ export default function AdminRoles() { {roles.length === 0 ? ( - ) - : roles.map((r) => ( - - - - + + + - - ))} + + + + + ); + })}
IDNomidRoleNom du rôlePermissions Actions
+ Aucun rôle enregistré
{r.id} - {editId === r.id - ? ( - - setEditNom( - (e.target as HTMLInputElement).value, - )} - style="min-width: 0; width: 100%" - /> - ) - : r.nom} - -
- {editId === r.id - ? ( - <> - - - - ) - : ( - <> - - - + : roles.map((r) => { + const shown = r.permissions.slice(0, MAX_CHIPS); + const overflow = r.permissions.length - MAX_CHIPS; + return ( +
{r.id} + {r.nom} + +
+ {shown.map((p) => ( + {p} + ))} + {overflow > 0 && ( + + +{overflow} + )} -
-
+
+ + +
+
)} - - {permissions.length > 0 && ( -
-

- Permissions disponibles -

-
- - - - - - - - - {permissions.map((p) => ( - - - - - ))} - -
IDNom
{p.id}{p.nom}
-
-
- )}
); } diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index a95fb54..5563bed 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -5,11 +5,13 @@ const properties: AppProperties = { icon: "school", pages: { index: "Accueil", - modules: "Modules", users: "Utilisateurs", roles: "Rôles", + permissions: "Permissions", + modules: "Modules", + enseignements: "Enseignements", }, - adminOnly: ["modules", "users", "roles"], + adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index cb2ab47..fd5fee8 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -17,6 +17,22 @@ const CONFLICT = new Response( ); 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, diff --git a/routes/(apps)/admin/partials/enseignements.tsx b/routes/(apps)/admin/partials/enseignements.tsx new file mode 100644 index 0000000..9b0127e --- /dev/null +++ b/routes/(apps)/admin/partials/enseignements.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Enseignements); diff --git a/routes/(apps)/admin/partials/permissions.tsx b/routes/(apps)/admin/partials/permissions.tsx new file mode 100644 index 0000000..f9359e5 --- /dev/null +++ b/routes/(apps)/admin/partials/permissions.tsx @@ -0,0 +1,18 @@ +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 const config = getPartialsConfig(); +export default makePartials(Permissions); diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx index d698c34..8c2ea22 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -1,19 +1,54 @@ 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 [newNom, setNewNom] = useState(""); - const [creating, setCreating] = useState(false); + + const [selectedUe, setSelectedUe] = useState(null); + + // 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); async function load() { try { - const res = await fetch("/notes/api/ues"); - if (!res.ok) throw new Error("Impossible de charger les UEs"); - setUes(await res.json()); + const [uRes, umRes, mRes, pRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch("/notes/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 { @@ -26,28 +61,37 @@ export default function AdminUEs() { }, []); async function createUE() { - if (!newNom.trim()) return; - setCreating(true); + if (!newUeNom.trim()) return; + setCreatingUe(true); try { const res = await fetch("/notes/api/ues", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: newNom.trim() }), + body: JSON.stringify({ nom: newUeNom.trim() }), }); if (!res.ok) throw new Error("Création échouée"); - setNewNom(""); + setNewUeNom(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { - setCreating(false); + setCreatingUe(false); } } - async function deleteUE(id: number) { - if (!confirm("Supprimer cette UE ?")) return; + async function deleteUeModule( + idModule: string, + idUE: number, + idPromo: string, + ) { + if (!confirm("Supprimer ce module de la UE ?")) return; try { - const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + const res = await fetch( + `/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { method: "DELETE" }, + ); if (!res.ok) throw new Error("Suppression échouée"); await load(); } catch (e) { @@ -55,68 +99,247 @@ export default function AdminUEs() { } } + async function addUeModule() { + if (!selectedUe || !addModuleId || !addPromoId) { + setAddError("Module 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("/notes/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); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const selectedUeModules = selectedUe + ? ueModules.filter((um) => um.idUE === selectedUe.id) + : []; + return (

Gestion des UEs

+

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

{error &&

{error}

} -
- setNewNom((e.target as HTMLInputElement).value)} - onKeyDown={(e) => e.key === "Enter" && createUE()} - /> - -
- {loading ?

Chargement…

: ( -
- - - - - - - - - - {ues.length === 0 - ? ( - - - - ) - : ues.map((ue) => ( - - - - - +
+ {/* Left panel – UE list */} +
+
+

UEs existantes

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

+ Aucune UE +

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

{selectedUe.nom}

+

+ Modules assignés (UE_Module) +

+
+ + + + + + + + + + + {selectedUeModules.length === 0 + ? ( + + + + ) + : selectedUeModules.map((um) => { + const mod = moduleMap[um.idModule]; + return ( + + + + + + + ); + })} + +
ModulePromoCoeffActions
+ Aucun module assigné +
+ {mod + ? `${mod.id} – ${mod.nom}` + : um.idModule} + + {um.idPromo} + {um.coeff} + +
+
+ +

+ Ajouter un module à cette UE +

+ {addError && ( +

+ {addError} +

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

+ Sélectionnez une UE pour voir ses modules +

+
+ )} +
)} diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx index 5461d9e..6143972 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -1,20 +1,42 @@ 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 [newId, setNewId] = useState(""); - const [newAnnee, setNewAnnee] = useState(""); 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()}` + : ""; + async function load() { try { - const res = await fetch("/students/api/promotions"); - if (!res.ok) throw new Error("Impossible de charger les promotions"); - setPromos(await res.json()); + 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 { @@ -27,23 +49,22 @@ export default function AdminPromotions() { }, []); async function createPromo() { - if (!newId.trim()) return; + if (!generatedId) return; setCreating(true); try { const res = await fetch("/students/api/promotions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - idPromo: newId.trim(), - annee: newAnnee.trim() || null, + idPromo: generatedId, + annee: selectedAnnee, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? "Création échouée"); } - setNewId(""); - setNewAnnee(""); + setAnneeSco(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -57,9 +78,7 @@ export default function AdminPromotions() { try { const res = await fetch( `/students/api/promotions/${encodeURIComponent(id)}`, - { - method: "DELETE", - }, + { method: "DELETE" }, ); if (!res.ok) throw new Error("Suppression échouée"); await load(); @@ -68,36 +87,93 @@ export default function AdminPromotions() { } } + function studentCount(idPromo: string) { + return students.filter((s) => s.idPromo === idPromo).length; + } + return (

Gestion des Promotions

{error &&

{error}

} -
- setNewId((e.target as HTMLInputElement).value)} - /> - setNewAnnee((e.target as HTMLInputElement).value)} - style="min-width: 14rem" - /> - + {/* PromoBuilder */} +
+

Créer une promotion

+

+ POST /promotions – 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…

: ( @@ -105,35 +181,51 @@ export default function AdminPromotions() { - + - + + + + {promos.length === 0 ? ( - ) - : promos.map((p) => ( - - - - - - ))} + : promos.map((p) => { + const parsed = parsePromo(p.id); + const count = studentCount(p.id); + return ( + + + + + + + + + ); + })}
IdentifiantidPromo AnnéeActionFilièreAnnée sco.Nb étudiantsActions
+ Aucune promotion enregistrée
{p.id}{p.annee ?? "—"} - -
+ {p.id} + {parsed.annee} + {parsed.filiere} + {parsed.anneeSco} + {count} étudiant{count !== 1 ? "s" : ""} + + +
diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index e69de29..f5728c4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -0,0 +1,247 @@ +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 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 [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] = await Promise.all([ + fetch(`/students/api/students/${numEtud}`), + fetch("/students/api/promotions"), + fetch("/admin/api/modules"), + ]); + 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()); + } 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

+

PUT /students/{"{numEtud}"}

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

Spécialisations

+

+ GET·POST·DELETE /spe5a – plusieurs modules possibles +

+

+ Fonctionnalité non disponible (endpoint non implémenté). +

+
+ + {/* Section 3: Notes lecture seule */} +
+

Notes (lecture seule)

+

+ GET /students/{"{numEtud}"}/notes – voir récap complet +

+
+ + Voir le récap complet des notes et moyennes de cet étudiant → + + + Récap notes + +
+
+
+ ); +} 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/static/styles/ui.css b/static/styles/ui.css index e56daa2..12132eb 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -391,3 +391,412 @@ margin-bottom: 1rem; gap: 1rem; } + +/* ------------------------------------------------------- + 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-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(10rem, 1fr)); + gap: 0.5rem; + 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; +} + +/* 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; +} + +.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; +} -- 2.52.0 From d3de5c29e778450c4089daa7221d728c5e85d408 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:08:58 +0200 Subject: [PATCH 085/103] refactor: add migration, seed permissions, update permissions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(notes): add XLSX import island and admin route feat(upload): add drag‑and‑drop upload, template download, UI tweaks --- compose.test.yml | 24 +- .../migrations/0001_seed_permissions.sql | 10 + .../0002_update_permission_names.sql | 13 + databases/migrations/meta/_journal.json | 14 ++ fresh.gen.ts | 6 + routes/(apps)/admin/(_islands)/AdminRoles.tsx | 6 +- routes/(apps)/admin/api/permissions.ts | 24 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 153 ++++++++++++ routes/(apps)/notes/(_props)/props.ts | 3 +- .../(apps)/notes/partials/(admin)/import.tsx | 29 +++ .../students/(_islands)/UploadStudents.tsx | 223 +++++++++++------- .../students/partials/(admin)/upload.tsx | 12 +- static/styles/main.css | 4 + static/styles/ui.css | 65 ++++- 14 files changed, 467 insertions(+), 119 deletions(-) create mode 100644 databases/migrations/0001_seed_permissions.sql create mode 100644 databases/migrations/0002_update_permission_names.sql create mode 100644 routes/(apps)/notes/(_islands)/ImportNotes.tsx create mode 100644 routes/(apps)/notes/partials/(admin)/import.tsx diff --git a/compose.test.yml b/compose.test.yml index 37b8e04..89a1142 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -7,9 +7,6 @@ services: POSTGRES_USER: postgres POSTGRES_DB: polympr_test volumes: - # Init script strips drizzle-kit markers and applies migrations on first start - - ./databases/docker-init.sh:/docker-entrypoint-initdb.d/01-migrate.sh:ro - - ./databases/migrations:/migrations:ro - db_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] @@ -17,6 +14,23 @@ services: 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 @@ -34,8 +48,8 @@ services: POSTGRES_DB: polympr_test LOCAL: "true" depends_on: - db: - condition: service_healthy + migrate: + condition: service_completed_successfully volumes: db_data_test: diff --git a/databases/migrations/0001_seed_permissions.sql b/databases/migrations/0001_seed_permissions.sql new file mode 100644 index 0000000..6ea1572 --- /dev/null +++ b/databases/migrations/0001_seed_permissions.sql @@ -0,0 +1,10 @@ +--> 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'); diff --git a/databases/migrations/0002_update_permission_names.sql b/databases/migrations/0002_update_permission_names.sql new file mode 100644 index 0000000..4e1b1d0 --- /dev/null +++ b/databases/migrations/0002_update_permission_names.sql @@ -0,0 +1,13 @@ +-- 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') +ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom"; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index ad99452..e4f070f 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "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 } ] } diff --git a/fresh.gen.ts b/fresh.gen.ts index a4a95f9..f19d57b 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -36,6 +36,7 @@ import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; 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_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.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"; @@ -72,6 +73,7 @@ import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; +import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; @@ -129,6 +131,8 @@ const manifest = { "./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/(admin)/ues.tsx": $_apps_notes_partials_admin_ues, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, @@ -187,6 +191,8 @@ const manifest = { $_apps_notes_islands_AdminConsultNotes, "./routes/(apps)/notes/(_islands)/AdminUEs.tsx": $_apps_notes_islands_AdminUEs, + "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": + $_apps_notes_islands_ImportNotes, "./routes/(apps)/notes/(_islands)/NotesView.tsx": $_apps_notes_islands_NotesView, "./routes/(apps)/students/(_islands)/AdminPromotions.tsx": diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index 448e334..b29b616 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -171,21 +171,19 @@ export default function AdminRoles() { ); })} diff --git a/routes/(apps)/admin/api/permissions.ts b/routes/(apps)/admin/api/permissions.ts index 1175eb0..61bf4ed 100644 --- a/routes/(apps)/admin/api/permissions.ts +++ b/routes/(apps)/admin/api/permissions.ts @@ -1,21 +1,15 @@ -import { Handlers } from "$fresh/server.ts"; +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"; -const PERMISSIONS = [ - { 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" }, -] as const; - export const handler: Handlers = { - GET(_request, _context): Response { - return new Response(JSON.stringify(PERMISSIONS), { + 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)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx new file mode 100644 index 0000000..e738057 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -0,0 +1,153 @@ +// @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 { useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; + +export default function ImportNotes() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const success = useSignal(null); + const inputRef = useRef(null); + + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; + return; + } + file.value = f; + error.value = null; + success.value = null; + } + + function onDragOver(e: DragEvent) { + e.preventDefault(); + dragging.value = true; + } + + 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; + success.value = null; + + try { + const arrayBuffer = await file.value.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + let imported = 0; + let failed = 0; + + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<{ + numEtud: number; + idModule: string; + note: number; + }>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 }); + + for (const row of rows) { + const res = await fetch("/notes/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(row), + }); + if (res.ok) imported++; + else failed++; + } + } + + success.value = + `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ + failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" + }`; + } catch { + error.value = "Erreur lors de la lecture du fichier."; + } finally { + uploading.value = false; + } + } + + function downloadTemplate() { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]); + XLSX.utils.book_append_sheet(wb, ws, "Notes"); + XLSX.writeFile(wb, "modele_notes.xlsx"); + } + + return ( +
+ + +
inputRef.current?.click()} + > + + {file.value + ? {file.value.name} + : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + {success.value && ( +

+ {success.value} +

+ )} + +
+ + +
+ +

+ Format : numEtud | idModule |{" "} + note +

+
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index fb7f11b..2f5be17 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -8,8 +8,9 @@ const properties: AppProperties = { notes: "Mes notes", courses: "Consulter", ues: "UEs", + import: "Import xlsx", }, - adminOnly: ["courses", "ues"], + adminOnly: ["courses", "ues", "import"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx new file mode 100644 index 0000000..4a92c3d --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -0,0 +1,29 @@ +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

+

+ POST /notes/api/notes +

+ +
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 6e21876..df6f592 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -1,111 +1,154 @@ // @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"; -/** - * 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 success = 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; + success.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; + success.value = null; + + try { + const arrayBuffer = await file.value.arrayBuffer(); const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let allOK = true; + let imported = 0; + let failed = 0; 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<{ + numEtud: number; + nom: string; + prenom: string; + }>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 }); - 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, idPromo: sheetName }), + }); + if (res.ok) imported++; + else failed++; } } - statusMessage.value = allOK - ? "Failed to insert all data." - : "Data uploaded and inserted successfully!"; - }; + success.value = + `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ + failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" + }`; + } 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() { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]); + XLSX.utils.book_append_sheet(wb, ws, "4A22"); + XLSX.writeFile(wb, "modele_etudiants.xlsx"); + } return ( - <> - - -

{statusMessage.value}

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

{error.value}

} + {success.value && ( +

+ {success.value} +

+ )} + +
+ + +
+ +

+ Format : promo (nom de la feuille) |{" "} + numEtud | nom |{" "} + prénom +

+
); } diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index 2f36f6d..cdb94fd 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -9,10 +9,16 @@ 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

+

+ POST /students/api/students/import-csv +

- +
); } 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 index 12132eb..88d3080 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -6,7 +6,6 @@ .page-content { padding: 1.5rem; - max-width: 960px; } .page-title { @@ -783,6 +782,70 @@ 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; +} + +.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; -- 2.52.0 From 378cbb0c06bfccd7c4be09e2b5b299f55b2bdd1d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:11:46 +0200 Subject: [PATCH 086/103] style: format import success message and drop zone JSX Apply consistent string concatenation in ImportNotes and UploadStudents. Format JSX drop zone for better readability. --- .../(apps)/notes/(_islands)/ImportNotes.tsx | 21 ++++++++----------- .../students/(_islands)/UploadStudents.tsx | 21 ++++++++----------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index e738057..4114c11 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -73,10 +73,9 @@ export default function ImportNotes() { } } - success.value = - `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ - failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" - }`; + success.value = `Import terminé — ${imported} ajouté${ + imported !== 1 ? "s" : "" + }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -109,14 +108,12 @@ export default function ImportNotes() { onClick={() => inputRef.current?.click()} > - {file.value - ? {file.value.name} - : ( - <> - Glisser le fichier .xlsx ici - ou cliquer pour parcourir - - )} + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )}
{error.value &&

{error.value}

} diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index df6f592..bf751d5 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -73,10 +73,9 @@ export default function UploadStudents() { } } - success.value = - `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ - failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" - }`; + success.value = `Import terminé — ${imported} ajouté${ + imported !== 1 ? "s" : "" + }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -109,14 +108,12 @@ export default function UploadStudents() { onClick={() => inputRef.current?.click()} > - {file.value - ? {file.value.name} - : ( - <> - Glisser le fichier .xlsx ici - ou cliquer pour parcourir - - )} + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} {error.value &&

{error.value}

} -- 2.52.0 From 757e364af0346bddc8fd923cd5e3be3354013546 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:29:31 +0200 Subject: [PATCH 087/103] chore(docker): add .dockerignore and update Dockerfile Add .dockerignore to exclude node_modules, .git, coverage, .env. Update Dockerfile to install nodejs/npm, copy package.json, run npm install, and build. Update compose.prod.yml to set working_dir, restart no, and use array command. Move drizzle-kit from devDependencies to dependencies. --- .dockerignore | 4 ++++ Dockerfile | 5 +++++ compose.prod.yml | 4 +++- package.json | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .dockerignore 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/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/compose.prod.yml b/compose.prod.yml index 6fcc5bc..6d7f11a 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -16,7 +16,9 @@ services: migrate: image: registry.docker.polytech.djalim.fr/polympr:latest - command: node_modules/.bin/drizzle-kit migrate + working_dir: /app + restart: "no" + command: ["node", "node_modules/.bin/drizzle-kit", "migrate"] env_file: .env depends_on: db: diff --git a/package.json b/package.json index bbd458d..3c2ff0c 100644 --- a/package.json +++ b/package.json @@ -1,12 +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", - "drizzle-kit": "^0.31.10", "tsx": "^4.21.0" } } -- 2.52.0 From 2c5e4ebf112d7d26d31b1e02fc9f5decac86cd5c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:22:23 +0200 Subject: [PATCH 088/103] feat(fresh.gen.ts): add routes for notes edition, recap and island recap feat(notes): add NoteRecap island component for student grade recap feat: add adjust controls to UI component Add placeholder, value binding, onInput handler, apply/reset buttons, and display of adjusted value. feat(notes): add edition and recap pages, update styles and links --- fresh.gen.ts | 8 + routes/(apps)/notes/(_islands)/NoteRecap.tsx | 385 +++++++++++++++++++ routes/(apps)/notes/edition/[numEtud].tsx | 12 + routes/(apps)/notes/recap/[numEtud].tsx | 12 + routes/_app.tsx | 6 +- static/styles/ui.css | 90 +++++ 6 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 routes/(apps)/notes/(_islands)/NoteRecap.tsx create mode 100644 routes/(apps)/notes/edition/[numEtud].tsx create mode 100644 routes/(apps)/notes/recap/[numEtud].tsx diff --git a/fresh.gen.ts b/fresh.gen.ts index f19d57b..22cab59 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -34,7 +34,9 @@ import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modul import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].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_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].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_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; @@ -74,6 +76,7 @@ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_ import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.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_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; @@ -128,7 +131,10 @@ const manifest = { $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, + "./routes/(apps)/notes/edition/[numEtud].tsx": + $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, + "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/(admin)/import.tsx": @@ -193,6 +199,8 @@ const manifest = { $_apps_notes_islands_AdminUEs, "./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)/students/(_islands)/AdminPromotions.tsx": diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx new file mode 100644 index 0000000..5ee4618 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -0,0 +1,385 @@ +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 }; +type Ajustement = { numEtud: number; idUE: number; valeur: 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"; +} + +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; value: string } | null + >(null); + const [ajustInputs, setAjustInputs] = useState>({}); + + async function load() { + try { + const sRes = await fetch(`/students/api/students/${numEtud}`); + if (!sRes.ok) throw new Error("Élève 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("/admin/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.note]))); + } + if (ajustRes.ok) { + const aj: Ajustement[] = await ajustRes.json(); + setAjustements(aj); + const inputs: Record = {}; + for (const a of aj) inputs[a.idUE] = String(a.valeur); + 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; + total += n * um.coeff; + coeff += um.coeff; + } + return coeff > 0 ? total / coeff : null; + } + + async function saveNote(idModule: string, value: string) { + const note = parseFloat(value.replace(",", ".")); + if (isNaN(note) || note < 0 || note > 20) { + setEditingNote(null); + return; + } + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated.note)); + } + setEditingNote(null); + } + + async function applyAjust(idUE: number) { + const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", ".")); + if (isNaN(val) || val < 0 || val > 20) 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 }), + }) + : await fetch("/notes/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud, idUE, valeur: val }), + }); + 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 à la liste + + +

+ Récap notes – {student.prenom} {student.nom} +

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

{error}

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

+ Aucune UE configurée 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); + + return ( +
+ {/* UE header */} +
+

{ue.nom}

+ {avg !== null && ( + + Moy. calculée : {fmt(avg)} + + )} + {ajust && ( + + ⚡ Ajust. actif : {fmt(ajust.valeur)} + + )} +
+ + {/* Module rows */} + {ueMods.length === 0 + ? ( +

+ Aucun module associé à cette UE pour cette promotion. +

+ ) + : ( +
+ {ueMods.map((um) => { + const noteVal = noteMap.get(um.idModule); + const nomMod = moduleMap.get(um.idModule) ?? um.idModule; + const isEditing = editingNote?.idModule === um.idModule; + + return ( +
+ + + {um.idModule} + + {nomMod} + + + coef {um.coeff} + + {isEditing + ? ( +
+ + setEditingNote({ + idModule: um.idModule, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + editingNote!.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote(um.idModule, editingNote!.value)} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + value: noteVal !== undefined + ? String(noteVal) + : "", + })} + > + {noteVal !== undefined ? fmt(noteVal) : "—/20"} + + )} + +
+ ); + })} +
+ )} + + {/* Ajustement */} +
+

Ajustement de la moyenne UE

+

+ Override ponctuel – laisser vide pour utiliser la moy. + calculée +

+
+
+ + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: (e.target as HTMLInputElement).value, + }))} + /> + /20 +
+ + {ajust && ( + <> + + + Affiché à l'élève : {fmt(ajust.valeur)} + {avg !== null ? ` (calculée : ${fmt(avg)})` : ""} + + + )} +
+
+
+ ); + })} +
+ ); +} 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/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/_app.tsx b/routes/_app.tsx index 8162820..81187c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -26,9 +26,9 @@ export default async function App( /> - - - + + +
diff --git a/static/styles/ui.css b/static/styles/ui.css index 88d3080..f43bfc8 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -857,6 +857,96 @@ 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) +------------------------------------------------------- */ + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); -- 2.52.0 From f162fcaadc56be45675cb1030f5dacbce402630d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:56:04 +0200 Subject: [PATCH 089/103] feat: add role_write permission and update e2e tests Add role_write permission to permissions table and update migrations. Update e2e tests to use DB integration and seed permissions. Add seedPermissions helper. --- .../migrations/0001_seed_permissions.sql | 3 +- .../0002_update_permission_names.sql | 3 +- tests/e2e/permissions_test.ts | 38 ++++++++++++++----- tests/helpers/db_integration.ts | 6 +++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/databases/migrations/0001_seed_permissions.sql b/databases/migrations/0001_seed_permissions.sql index 6ea1572..922f6fa 100644 --- a/databases/migrations/0001_seed_permissions.sql +++ b/databases/migrations/0001_seed_permissions.sql @@ -7,4 +7,5 @@ INSERT INTO "permissions" ("id", "nom") VALUES ('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'); + ('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 index 4e1b1d0..d598c10 100644 --- a/databases/migrations/0002_update_permission_names.sql +++ b/databases/migrations/0002_update_permission_names.sql @@ -9,5 +9,6 @@ INSERT INTO "permissions" ("id", "nom") VALUES ('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') + ('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/tests/e2e/permissions_test.ts b/tests/e2e/permissions_test.ts index 158c82a..8dff05d 100644 --- a/tests/e2e/permissions_test.ts +++ b/tests/e2e/permissions_test.ts @@ -1,24 +1,40 @@ // #115 - E2E tests for GET /permissions -// Handler statique (pas de DB), test direct du handler 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", - fn() { - const res = permissionsHandler.GET!( + async fn() { + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( makeGetRequest("/permissions"), makeEmployeeContext(), ); assertEquals(res.status, 200); - return res.text().then((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")); - }); + 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, @@ -27,7 +43,9 @@ Deno.test({ Deno.test({ name: "e2e permissions: GET /permissions - all entries have id and nom", async fn() { - const res = permissionsHandler.GET!( + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( makeGetRequest("/permissions"), makeEmployeeContext(), ); diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 4b91b25..2a571bf 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -111,3 +111,9 @@ export async function seedAjustements( ): 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(); +} -- 2.52.0 From bb09c1cce5f64eac26a63d05c9a84e15b505c4f6 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:58:19 +0200 Subject: [PATCH 090/103] chore: formated tests --- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 22 +++++++++++++------- tests/e2e/permissions_test.ts | 10 ++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index 5ee4618..81918f5 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -1,8 +1,18 @@ import { useEffect, useState } from "preact/hooks"; -type Student = { numEtud: number; nom: string; prenom: string; idPromo: string }; +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 UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; type Module = { id: string; nom: string }; type Note = { numEtud: number; idModule: string; note: number }; type Ajustement = { numEtud: number; idUE: number; valeur: number }; @@ -202,9 +212,7 @@ export default function NoteRecap({ numEtud }: Props) { return (
{/* UE header */} -
+

{ue.nom}

{avg !== null && ( @@ -254,9 +262,7 @@ export default function NoteRecap({ numEtud }: Props) { {isEditing ? ( -
+
Date: Sun, 26 Apr 2026 20:47:41 +0200 Subject: [PATCH 091/103] PMPR-44 : POST /notes/import-xlsx - importer des notes via Excel --- routes/(apps)/notes/api/notes/import-xlsx.ts | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 routes/(apps)/notes/api/notes/import-xlsx.ts 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..134f3d2 --- /dev/null +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -0,0 +1,49 @@ +// @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 }[]; + + for (const row of rows) { + const { numEtud, note } = row; + + if (!numEtud || note === undefined) { + continue; + } + + await db.insert(notes) + .values({ numEtud, idModule, note }) + .onConflictDoUpdate({ + target: [notes.numEtud, notes.idModule], + set: { note }, + }); + } + + return new Response(null, { status: 204 }); + } catch (error) { + console.error("Error importing notes:", error); + return new Response("Failed to import notes", { status: 500 }); + } + }, +}; \ No newline at end of file -- 2.52.0 From 720a380be816331b28dae5a2fecc3455d6ffce2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Sun, 26 Apr 2026 23:28:50 +0200 Subject: [PATCH 092/103] PMPR-44 : fix formatting --- routes/(apps)/notes/api/notes/import-xlsx.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/(apps)/notes/api/notes/import-xlsx.ts b/routes/(apps)/notes/api/notes/import-xlsx.ts index 134f3d2..3e4ce09 100644 --- a/routes/(apps)/notes/api/notes/import-xlsx.ts +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -5,7 +5,7 @@ import { db } from "../../../../../databases/db.ts"; import { notes } from "../../../../../databases/schema.ts"; export const handler: Handlers = { - // # 44 POST /notes/import-xlsx + //# 44 POST /notes/import-xlsx async POST(request) { try { const formData = await request.formData(); @@ -23,7 +23,10 @@ export const handler: Handlers = { 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 }[]; + const rows = XLSX.utils.sheet_to_json(sheet) as { + numEtud: number; + note: number; + }[]; for (const row of rows) { const { numEtud, note } = row; -- 2.52.0 From f71128a7f3158f9aa6cfeb3110b90adf173d79dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Oudelet?= Date: Sun, 26 Apr 2026 23:33:45 +0200 Subject: [PATCH 093/103] PMPR-44 : fix missing newline --- routes/(apps)/notes/api/notes/import-xlsx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/(apps)/notes/api/notes/import-xlsx.ts b/routes/(apps)/notes/api/notes/import-xlsx.ts index 3e4ce09..b31079b 100644 --- a/routes/(apps)/notes/api/notes/import-xlsx.ts +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -49,4 +49,4 @@ export const handler: Handlers = { return new Response("Failed to import notes", { status: 500 }); } }, -}; \ No newline at end of file +}; -- 2.52.0 From 04be659d6b2e29611af66ee94e5f8cdfc5c0cf8d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 29 Apr 2026 09:12:55 +0200 Subject: [PATCH 094/103] feat(app): add studentOnly pages and new routes Add routes for modules, users, notes import, recap, and islands edit. Update middleware to filter pages based on user role. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(admin): add modal for assigning teaching, replace delete icon with SVG refactor(server): rename port variable to uppercase and add env support feat(admin): add enseignants, users, filtering and role colors refactor(AdminRoles): improve role UI and add permission mapping feat(admin-users): add role colors, role filter, and modal for creating users feat(admin): add EditModule component for module editing feat(admin): add EditUser page for editing users and managing enseignements feat(promo-select): display id and name in options for promo dropdown feat: add edit module/user routes, inline coeff editing, UI tweaks refactor: UI – icons, modal overlay, grid, subtitles, import margin --- defaults/interfaces.ts | 1 + fresh.gen.ts | 18 +- routes/(apps)/_middleware.ts | 11 +- .../admin/(_islands)/AdminEnseignements.tsx | 139 ++++--- .../(apps)/admin/(_islands)/AdminModules.tsx | 254 +++++++----- .../admin/(_islands)/AdminPermissions.tsx | 23 +- routes/(apps)/admin/(_islands)/AdminRoles.tsx | 53 ++- routes/(apps)/admin/(_islands)/AdminUsers.tsx | 195 +++++++-- routes/(apps)/admin/(_islands)/EditModule.tsx | 344 +++++++++++++++ routes/(apps)/admin/(_islands)/EditUser.tsx | 391 ++++++++++++++++++ routes/(apps)/admin/modules/[idModule].tsx | 11 + routes/(apps)/admin/users/[id].tsx | 11 + .../notes/(_islands)/AdminConsultNotes.tsx | 12 +- routes/(apps)/notes/(_islands)/AdminUEs.tsx | 103 ++++- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 12 +- routes/(apps)/notes/(_props)/props.ts | 1 + .../(apps)/notes/partials/(admin)/import.tsx | 6 - .../students/(_islands)/AdminPromotions.tsx | 19 +- .../students/(_islands)/ConsultStudents.tsx | 25 +- .../students/(_islands)/EditStudents.tsx | 8 - .../students/partials/(admin)/upload.tsx | 6 - static/styles/ui.css | 57 ++- 22 files changed, 1455 insertions(+), 245 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/EditModule.tsx create mode 100644 routes/(apps)/admin/(_islands)/EditUser.tsx create mode 100644 routes/(apps)/admin/modules/[idModule].tsx create mode 100644 routes/(apps)/admin/users/[id].tsx diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index f385846..9b65a28 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -19,6 +19,7 @@ export interface AppProperties { icon: string; pages: Record; adminOnly: string[]; + studentOnly?: string[]; hint: string; } diff --git a/fresh.gen.ts b/fresh.gen.ts index 22cab59..ffa7923 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,12 +15,14 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles 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_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_roles from "./routes/(apps)/admin/partials/roles.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_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.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"; @@ -30,18 +32,19 @@ import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustem import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].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_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].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_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].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_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.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_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"; @@ -70,6 +73,8 @@ import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_isla import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx"; import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.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_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"; @@ -103,6 +108,8 @@ const manifest = { "./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/index.tsx": $_apps_admin_partials_index, @@ -111,6 +118,7 @@ const manifest = { $_apps_admin_partials_permissions, "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, + "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, "./routes/(apps)/mobility/api/insert_mobility.ts": $_apps_mobility_api_insert_mobility, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, @@ -126,6 +134,8 @@ const manifest = { "./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/ue-modules/[idModule]/[idUE]/[idPromo].ts": $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, @@ -134,7 +144,6 @@ const manifest = { "./routes/(apps)/notes/edition/[numEtud].tsx": $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, - "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/(admin)/import.tsx": @@ -143,6 +152,7 @@ const manifest = { $_apps_notes_partials_admin_ues, "./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)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -187,6 +197,10 @@ const manifest = { $_apps_admin_islands_AdminRoles, "./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)/mobility/(_islands)/ConsultMobility.tsx": $_apps_mobility_islands_ConsultMobility, "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index f30f19f..e60886b 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -22,13 +22,18 @@ export const handler: MiddlewareHandler[] = [ )).default; context.state.availablePages = properties.pages; - if ( + const isStudent = context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true" - ) { + Deno.env.get("LOCAL") != "true"; + + if (isStudent) { properties.adminOnly.forEach((page) => delete context.state.availablePages[page] ); + } else { + 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 index 7b158d2..2a0c2af 100644 --- a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -169,55 +169,77 @@ export default function AdminEnseignements() {
{showAdd && ( -
- {addError && ( - - {addError} - - )} - - - setAddProf((e.target as HTMLInputElement).value)} - style="min-width: 10rem" - /> - - + )} @@ -266,7 +288,24 @@ export default function AdminEnseignements() { e.idPromo, )} > - 🗑 + + + + +
diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx index df0af41..3a89778 100644 --- a/routes/(apps)/admin/(_islands)/AdminModules.tsx +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -1,22 +1,31 @@ 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 [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + const [filterNom, setFilterNom] = useState(""); async function load() { try { - const res = await fetch("/admin/api/modules"); - if (!res.ok) throw new Error("Impossible de charger les modules"); - setModules(await res.json()); + 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 modules"); + 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 { @@ -51,21 +60,6 @@ export default function AdminModules() { } } - async function saveEdit(id: string) { - try { - const res = await fetch(`/admin/api/modules/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: editNom.trim() }), - }); - if (!res.ok) throw new Error("Modification échouée"); - setEditId(null); - await load(); - } catch (e) { - setError(e instanceof Error ? e.message : "Erreur"); - } - } - async function deleteModule(id: string) { if (!confirm(`Supprimer le module ${id} ?`)) return; try { @@ -80,125 +74,181 @@ export default function AdminModules() { } } + 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 Modules

{error &&

{error}

} -
+
setNewId((e.target as HTMLInputElement).value)} - style="min-width: 10rem" - /> - setNewNom((e.target as HTMLInputElement).value)} + class="filter-input" + placeholder="Rechercher..." + value={filterNom} + onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)} />
{loading - ?

Chargement…

+ ?

Chargement...

: (
- - + + + - {modules.length === 0 + {filtered.length === 0 ? ( - ) - : modules.map((m) => ( - - - - + + + - - ))} + : --} + + + + ); + })}
IdentifiantNomid (code)Nom du moduleEnseignants assignes Actions
+ Aucun module enregistré
{m.id} - {editId === m.id - ? ( - - setEditNom( - (e.target as HTMLInputElement).value, - )} - style="min-width: 0; width: 100%" - /> - ) - : m.nom} - -
- {editId === m.id + : filtered.map((m) => { + const profs = enseignantsForModule(m.id); + return ( +
{m.id}{m.nom} + {profs ? ( - <> - - - + + {profs} + ) - : ( - <> - - - - )} - -
+
+ + + + {" "} + edit + + +
+
)} + + {/* Nouveau module */} +
+

Nouveau module

+
+ 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 index 57c600b..79ed125 100644 --- a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -3,6 +3,19 @@ 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([]); @@ -80,7 +93,15 @@ export default function AdminPermissions() {
{shown.map((r) => ( - {r.nom} + + {r.nom} + ))} {overflow > 0 && ( {saveError}

} -
+
idRole : {managingRole.id} {managingRole.nom} - + {activeCount} permission{activeCount !== 1 ? "s" : ""} active {activeCount !== 1 ? "s" : ""} @@ -151,7 +148,7 @@ export default function AdminRoles() { onClick={savePerms} disabled={saving} > - {saving ? "…" : "Enregistrer"} + {saving ? "..." : "Enregistrer"}
@@ -192,6 +189,8 @@ export default function AdminRoles() { ); } + const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom])); + // ---- Main list view ---- return (
@@ -202,7 +201,7 @@ export default function AdminRoles() {
setNewNom((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === "Enter" && createRole()} @@ -219,7 +218,7 @@ export default function AdminRoles() {
{loading - ?

Chargement…

+ ?

Chargement...

: (
@@ -252,7 +251,9 @@ export default function AdminRoles() { diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx index eee86f9..0ca35b6 100644 --- a/routes/(apps)/admin/(_islands)/AdminUsers.tsx +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -3,11 +3,25 @@ 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(""); @@ -15,6 +29,7 @@ export default function AdminUsers() { const [creating, setCreating] = useState(false); const [filterNom, setFilterNom] = useState(""); + const [filterRole, setFilterRole] = useState(""); async function load() { try { @@ -58,6 +73,7 @@ export default function AdminUsers() { setNewNom(""); setNewPrenom(""); setNewIdRole(""); + setShowCreate(false); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -81,12 +97,14 @@ export default function AdminUsers() { const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom])); - const filtered = users.filter((u) => - !filterNom || - `${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes( - filterNom.toLowerCase(), - ) - ); + 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 (
@@ -94,64 +112,121 @@ export default function AdminUsers() { {error &&

{error}

} -
+
setNewId((e.target as HTMLInputElement).value)} - style="min-width: 9rem" - /> - setNewNom((e.target as HTMLInputElement).value)} - /> - setNewPrenom((e.target as HTMLInputElement).value)} + class="filter-input" + placeholder="Rechercher..." + value={filterNom} + onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)} />
-
- setFilterNom((e.target as HTMLInputElement).value)} - /> -
+ {/* Creation modal */} + {showCreate && ( + + )} {loading - ?

Chargement…

+ ?

Chargement...

: (
{shown.map((p) => ( - {p} + + {permMap[p] ?? p} + ))} {overflow > 0 && (
- + - + @@ -170,7 +245,18 @@ export default function AdminUsers() { diff --git a/routes/(apps)/admin/(_islands)/EditModule.tsx b/routes/(apps)/admin/(_islands)/EditModule.tsx new file mode 100644 index 0000000..a9770ba --- /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("Module 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("Module enregistré."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteModule() { + if (!confirm(`Supprimer définitivement le module ${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 + + +

+ Module -- {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 + ? ( +
+
Loginid (login) Nom PrénomRôleRôle(s) Actions
{u.nom} {u.prenom} - {u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"} + {u.idRole + ? ( + + {roleMap[u.idRole] ?? `#${u.idRole}`} + + ) + : --}
@@ -179,14 +265,35 @@ export default function AdminUsers() { href={`/admin/users/${encodeURIComponent(u.id)}`} f-client-nav={false} > - ✏ + + + {" "} + edit
+ + + + + + + + + {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..c9e45ca --- /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("Module 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

+

+ Modules enseignes par cet utilisateur +

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

+ Aucun enseignement assigne. +

+ )} + +

+ Ajouter un enseignement +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} 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/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)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx index 9ae2c94..dd4abae 100644 --- a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -130,7 +130,17 @@ export default function AdminConsultNotes() { href={`/notes/edition/${s.numEtud}`} f-client-nav={false} > - ✏ édit + + + {" "} + édit
diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx index 8c2ea22..bbed5df 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -31,6 +31,10 @@ export default function AdminUEs() { 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([ @@ -137,6 +141,32 @@ export default function AdminUEs() { } } + async function updateCoeff( + idModule: string, + idUE: number, + idPromo: string, + coeff: number, + ) { + try { + const res = await fetch( + `/notes/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])); const selectedUeModules = selectedUe @@ -249,7 +279,59 @@ export default function AdminUEs() { {um.idPromo} - {um.coeff} + { + 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} + diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index 81918f5..f72bc89 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -326,7 +326,17 @@ export default function NoteRecap({ numEtud }: Props) { : "", })} > - ✏ note + + + {" "} + note
); diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 2f5be17..2e4dc98 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -11,6 +11,7 @@ const properties: AppProperties = { import: "Import xlsx", }, adminOnly: ["courses", "ues", "import"], + studentOnly: ["notes"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx index 4a92c3d..111edf0 100644 --- a/routes/(apps)/notes/partials/(admin)/import.tsx +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -14,12 +14,6 @@ async function ImportNotesPage( return (

Importer des Notes

-

- POST /notes/api/notes -

); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx index 6143972..7f32f91 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -25,7 +25,7 @@ export default function AdminPromotions() { const [anneeSco, setAnneeSco] = useState(""); const generatedId = anneeSco.trim() - ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}` + ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}` : ""; async function load() { @@ -101,7 +101,7 @@ export default function AdminPromotions() {

Créer une promotion

- POST /promotions – idPromo est généré automatiquement + idPromo est généré automatiquement

@@ -141,7 +141,7 @@ export default function AdminPromotions() { setAnneeSco((e.target as HTMLInputElement).value)} style="min-width: 9rem" @@ -220,7 +220,18 @@ export default function AdminPromotions() { class="btn btn-sm btn-danger" onClick={() => deletePromo(p.id)} > - 🗑 + + + + + diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 031bbe9..c55ae51 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -67,6 +67,7 @@ export default function ConsultStudents() { class="btn btn-primary" href="/students/upload" f-partial="/students/partials/upload" + style="margin-left: auto" > Importer xlsx @@ -128,14 +129,34 @@ export default function ConsultStudents() { href={`/students/edit/${s.numEtud}`} f-client-nav={false} > - ✏ + + +
diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index f5728c4..a7fc770 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -147,8 +147,6 @@ export default function EditStudents({ numEtud }: Props) { {/* Section 1: Informations générales */}

Informations générales

-

PUT /students/{"{numEtud}"}

-
@@ -212,9 +210,6 @@ export default function EditStudents({ numEtud }: Props) { {/* Section 2: Spécialisations */}

Spécialisations

-

- GET·POST·DELETE /spe5a – plusieurs modules possibles -

Notes (lecture seule)

-

- GET /students/{"{numEtud}"}/notes – voir récap complet -

Voir le récap complet des notes et moyennes de cet étudiant → diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index cdb94fd..578d830 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -11,12 +11,6 @@ async function Students(_request: Request, _context: FreshContext) { return (

Importer des Élèves

-

- POST /students/api/students/import-csv -

); diff --git a/static/styles/ui.css b/static/styles/ui.css index f43bfc8..a4efd9a 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -470,6 +470,18 @@ 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; @@ -740,7 +752,7 @@ .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); - gap: 0.5rem; + gap: 0.75rem 1rem; margin-bottom: 0.75rem; } @@ -947,6 +959,49 @@ (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)); -- 2.52.0 From df3957741d72696183e06d87bbb0641e0b493521 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Thu, 30 Apr 2026 13:49:47 +0200 Subject: [PATCH 095/103] feat : fix a lot of stuff --- .../0003_add_session2_and_malus.sql | 3 + databases/migrations/meta/_journal.json | 7 + databases/schema.ts | 2 + defaults/ImportResultPopup.tsx | 102 +++ fresh.gen.ts | 47 +- routes/(apps)/_middleware.ts | 10 +- .../(_islands)/AdminPromotions.tsx | 21 +- .../{notes => admin}/(_islands)/AdminUEs.tsx | 98 ++- .../admin/(_islands)/ImportMaquette.tsx | 531 ++++++++++++++++ routes/(apps)/admin/(_props)/props.ts | 5 +- routes/(apps)/admin/api/enseignements.ts | 24 +- .../[idProf]/[idModule]/[idPromo].ts | 19 +- routes/(apps)/admin/api/modules.ts | 8 +- routes/(apps)/admin/api/modules/[idModule].ts | 43 +- routes/(apps)/admin/api/roles/[idRole].ts | 37 +- .../(apps)/{notes => admin}/api/ue-modules.ts | 0 .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 38 +- routes/(apps)/{notes => admin}/api/ues.ts | 0 .../(apps)/{notes => admin}/api/ues/[idUE].ts | 17 +- routes/(apps)/admin/api/users/[id].ts | 34 +- .../(apps)/admin/partials/import-maquette.tsx | 23 + .../(admin) => admin/partials}/promotions.tsx | 2 +- .../(admin) => admin/partials}/ues.tsx | 2 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 587 ++++++++++++++++-- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 348 ++++++++--- routes/(apps)/notes/(_islands)/NotesView.tsx | 100 +-- routes/(apps)/notes/(_props)/props.ts | 5 +- routes/(apps)/notes/api/ajustements.ts | 19 +- .../notes/api/ajustements/[numEtud]/[idUE].ts | 42 +- routes/(apps)/notes/api/notes.ts | 29 +- .../notes/api/notes/[numEtud]/[idModule].ts | 34 +- routes/(apps)/notes/api/notes/import-xlsx.ts | 24 +- routes/(apps)/notes/partials/notes.tsx | 45 +- .../students/(_islands)/ConsultStudents.tsx | 146 ++++- .../students/(_islands)/UploadStudents.tsx | 72 ++- routes/(apps)/students/(_props)/props.ts | 3 +- .../students/api/promotions/[idPromo].ts | 126 +++- routes/(apps)/students/api/students.ts | 16 +- .../(apps)/students/api/students/[numEtud].ts | 66 +- routes/dev-login.ts | 74 ++- routes/login.tsx | 2 + scripts/generate-templates.ts | 60 ++ scripts/inspect-maquette.ts | 25 + static/styles/ui.css | 193 ++++++ static/templates/modele_etudiants.xlsx | Bin 0 -> 16207 bytes static/templates/modele_maquette.xlsx | Bin 0 -> 17926 bytes static/templates/modele_notes.xlsx | Bin 0 -> 16337 bytes tests/e2e/robustness_test.ts | 4 +- tests/e2e/ue_modules_test.ts | 4 +- tests/e2e/ues_test.ts | 4 +- 50 files changed, 2664 insertions(+), 437 deletions(-) create mode 100644 databases/migrations/0003_add_session2_and_malus.sql create mode 100644 defaults/ImportResultPopup.tsx rename routes/(apps)/{students => admin}/(_islands)/AdminPromotions.tsx (92%) rename routes/(apps)/{notes => admin}/(_islands)/AdminUEs.tsx (82%) create mode 100644 routes/(apps)/admin/(_islands)/ImportMaquette.tsx rename routes/(apps)/{notes => admin}/api/ue-modules.ts (100%) rename routes/(apps)/{notes => admin}/api/ue-modules/[idModule]/[idUE]/[idPromo].ts (81%) rename routes/(apps)/{notes => admin}/api/ues.ts (100%) rename routes/(apps)/{notes => admin}/api/ues/[idUE].ts (86%) create mode 100644 routes/(apps)/admin/partials/import-maquette.tsx rename routes/(apps)/{students/partials/(admin) => admin/partials}/promotions.tsx (86%) rename routes/(apps)/{notes/partials/(admin) => admin/partials}/ues.tsx (88%) create mode 100644 scripts/generate-templates.ts create mode 100644 scripts/inspect-maquette.ts create mode 100644 static/templates/modele_etudiants.xlsx create mode 100644 static/templates/modele_maquette.xlsx create mode 100644 static/templates/modele_notes.xlsx 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/meta/_journal.json b/databases/migrations/meta/_journal.json index e4f070f..f81c27d 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777155028710, "tag": "0002_update_permission_names", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777155028711, + "tag": "0003_add_session2_and_malus", + "breakpoints": true } ] } diff --git a/databases/schema.ts b/databases/schema.ts index 823c7a2..9bf678d 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -75,6 +75,7 @@ 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] }), })); @@ -83,6 +84,7 @@ 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] }), })); 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/fresh.gen.ts b/fresh.gen.ts index ffa7923..bd47e97 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -12,15 +12,22 @@ import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/m 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_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; @@ -33,15 +40,10 @@ import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/not 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_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; -import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; -import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].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_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.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"; @@ -53,7 +55,6 @@ import * as $_apps_students_api_students_import_csv from "./routes/(apps)/studen 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_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; @@ -71,19 +72,20 @@ import * as $_islands_Navbar from "./routes/(_islands)/Navbar.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_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_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; -import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.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_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.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"; @@ -105,6 +107,11 @@ const manifest = { "./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, @@ -112,11 +119,16 @@ const manifest = { $_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/api/insert_mobility.ts": @@ -136,11 +148,6 @@ const manifest = { $_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/ue-modules/[idModule]/[idUE]/[idPromo].ts": - $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, - "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, - "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, "./routes/(apps)/notes/edition/[numEtud].tsx": $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, @@ -148,8 +155,6 @@ const manifest = { $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/(admin)/import.tsx": $_apps_notes_partials_admin_import, - "./routes/(apps)/notes/partials/(admin)/ues.tsx": - $_apps_notes_partials_admin_ues, "./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_, @@ -167,8 +172,6 @@ const manifest = { "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, - "./routes/(apps)/students/partials/(admin)/promotions.tsx": - $_apps_students_partials_admin_promotions, "./routes/(apps)/students/partials/(admin)/upload.tsx": $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": @@ -193,14 +196,20 @@ const manifest = { $_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)/ConsultMobility.tsx": $_apps_mobility_islands_ConsultMobility, "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": @@ -209,16 +218,12 @@ const manifest = { $_apps_mobility_islands_ImportFile, "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": $_apps_notes_islands_AdminConsultNotes, - "./routes/(apps)/notes/(_islands)/AdminUEs.tsx": - $_apps_notes_islands_AdminUEs, "./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)/students/(_islands)/AdminPromotions.tsx": - $_apps_students_islands_AdminPromotions, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index e60886b..ece0de4 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,16 +21,20 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = properties.pages; + context.state.availablePages = { ...properties.pages }; const isStudent = - context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true"; + context.state.session.eduPersonPrimaryAffiliation === "student"; + const isLocal = Deno.env.get("LOCAL") === "true"; 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] ); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx similarity index 92% rename from routes/(apps)/students/(_islands)/AdminPromotions.tsx rename to routes/(apps)/admin/(_islands)/AdminPromotions.tsx index 7f32f91..68c71c6 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx @@ -74,13 +74,26 @@ export default function AdminPromotions() { } async function deletePromo(id: string) { - if (!confirm(`Supprimer la promotion ${id} ?`)) return; + 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) throw new Error("Suppression échouée"); + 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"); @@ -218,6 +231,10 @@ export default function AdminPromotions() {
))} - {ues.length === 0 && ( + {filteredUes.length === 0 && (

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

)}
diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx new file mode 100644 index 0000000..676e283 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -0,0 +1,531 @@ +// @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: `Module ${mod.code} "${mod.name}" cree`, + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ + type: "error", + message: `Module "${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} modules + +

+ +
+ +
+ + + + + + + + + + + {year.ues.map((ue, i) => + ue.modules.length === 0 + ? ( + + + + + ) + : ue.modules.map((mod, j) => ( + + {j === 0 && ( + + )} + + + + + )) + )} + +
UEModuleCodeCoeff
{ue.name} + Aucun module +
+ {ue.name} + {ue.ects != null && ( + + {" "}({ue.ects} ECTS) + + )} + {mod.name}{mod.code}{mod.coeff}
+
+
+ ); + })} +
+ )} + +
+ + + +
+ +

+ Format : fichier maquette FISE / FISA avec lignes UE + {" "}et modules (colonnes code, nom, coefficient) +

+
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 5563bed..a681b46 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -10,8 +10,11 @@ const properties: AppProperties = { permissions: "Permissions", modules: "Modules", enseignements: "Enseignements", + promotions: "Promotions", + ues: "UEs", + "import-maquette": "Import Maquette", }, - adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], + adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index fd5fee8..bae6a2c 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -4,17 +4,19 @@ 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 _NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +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" } }, -); +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 @@ -39,7 +41,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } let body: { idProf: string; idModule: string; idPromo: string }; @@ -67,7 +69,7 @@ export const handler: Handlers = { .then((rows) => rows[0] ?? null); if (existing) { - return CONFLICT; + return CONFLICT(); } const [created] = await db diff --git a/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts index 30dbd8a..27cc6e2 100644 --- a/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts +++ b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts @@ -4,12 +4,13 @@ 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 NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #30 GET /enseignements/{idProf}/{idModule}/{idPromo} @@ -18,7 +19,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idProf = context.params.idProf; @@ -37,7 +38,7 @@ export const handler: Handlers = { ) .then((rows) => rows[0] ?? null); - if (!enseignement) return NOT_FOUND; + if (!enseignement) return NOT_FOUND(); return new Response(JSON.stringify(enseignement), { headers: { "content-type": "application/json" }, @@ -50,7 +51,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idProf = context.params.idProf; @@ -68,7 +69,7 @@ export const handler: Handlers = { ) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index bdb37b9..63ebfe1 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -8,14 +8,8 @@ export const handler: Handlers = { // #23 GET /modules async GET( _request: Request, - context: FreshContext, + _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(modules); return new Response(JSON.stringify(rows), { headers: { "content-type": "application/json" }, diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index d3d9467..8c3f91f 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -1,13 +1,19 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { modules } from "$root/databases/schema.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" } }, -); +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} @@ -21,7 +27,7 @@ export const handler: Handlers = { .where(eq(modules.id, context.params.idModule)) .then((rows) => rows[0] ?? null); - if (!module) return NOT_FOUND; + if (!module) return NOT_FOUND(); return new Response(JSON.stringify(module), { headers: { "content-type": "application/json" }, @@ -50,7 +56,7 @@ export const handler: Handlers = { .where(eq(modules.id, context.params.idModule)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -58,16 +64,29 @@ export const handler: Handlers = { }, // #27 DELETE /modules/{idModule} + // Cascade: deletes notes, ue_modules, enseignements for this module. async DELETE( _request: Request, context: FreshContext, ): Promise { - const [deleted] = await db - .delete(modules) - .where(eq(modules.id, context.params.idModule)) - .returning(); + const idModule = context.params.idModule; - if (!deleted) return NOT_FOUND; + 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/roles/[idRole].ts b/routes/(apps)/admin/api/roles/[idRole].ts index d29d047..7b15c8c 100644 --- a/routes/(apps)/admin/api/roles/[idRole].ts +++ b/routes/(apps)/admin/api/roles/[idRole].ts @@ -1,13 +1,14 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { rolePermissions, roles } from "$root/databases/schema.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" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); async function getRoleWithPermissions( id: number, @@ -41,7 +42,7 @@ export const handler: Handlers = { const id = Number(context.params.idRole); const role = await getRoleWithPermissions(id); - if (!role) return NOT_FOUND; + if (!role) return NOT_FOUND(); return new Response(JSON.stringify(role), { headers: { "content-type": "application/json" }, @@ -62,7 +63,7 @@ export const handler: Handlers = { .where(eq(roles.id, id)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); // Reset permissions await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); @@ -80,21 +81,29 @@ export const handler: Handlers = { }, // #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); - // Cascade delete role_permissions first - await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); - - const [deleted] = await db - .delete(roles) + const role = await db + .select() + .from(roles) .where(eq(roles.id, id)) - .returning(); + .then((r) => r[0] ?? null); - if (!deleted) return NOT_FOUND; + 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)/notes/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts similarity index 100% rename from routes/(apps)/notes/api/ue-modules.ts rename to routes/(apps)/admin/api/ue-modules.ts diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts similarity index 81% rename from routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts rename to routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index f447f12..7470e7f 100644 --- a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -4,17 +4,19 @@ 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-Module introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Association UE-Module introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +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" } }, -); +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} @@ -23,7 +25,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -31,7 +33,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const ueModuleAssociation = await db @@ -44,7 +46,7 @@ export const handler: Handlers = { ) .then((rows) => rows[0] ?? null); - if (!ueModuleAssociation) return NOT_FOUND; + if (!ueModuleAssociation) return NOT_FOUND(); return new Response(JSON.stringify(ueModuleAssociation), { headers: { "content-type": "application/json" }, @@ -57,7 +59,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -65,7 +67,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const body: { coeff: number } = await request.json(); @@ -89,7 +91,7 @@ export const handler: Handlers = { ) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response( JSON.stringify({ @@ -110,7 +112,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -118,7 +120,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const [deleted] = await db @@ -132,7 +134,7 @@ export const handler: Handlers = { ) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/admin/api/ues.ts similarity index 100% rename from routes/(apps)/notes/api/ues.ts rename to routes/(apps)/admin/api/ues.ts diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/admin/api/ues/[idUE].ts similarity index 86% rename from routes/(apps)/notes/api/ues/[idUE].ts rename to routes/(apps)/admin/api/ues/[idUE].ts index c8f586f..92f6e1a 100644 --- a/routes/(apps)/notes/api/ues/[idUE].ts +++ b/routes/(apps)/admin/api/ues/[idUE].ts @@ -1,6 +1,10 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../../databases/db.ts"; -import { ues } from "../../../../../databases/schema.ts"; +import { + ajustements, + ueModules, + ues, +} from "../../../../../databases/schema.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { @@ -87,6 +91,7 @@ export const handler: Handlers = { }, // #36 DELETE /ues/:idUE + // Cascade: deletes ajustements, ue_modules for this UE. async DELETE(_request, context) { try { const idUE = parseInt(context.params.idUE); @@ -101,9 +106,9 @@ export const handler: Handlers = { ); } - const result = await db.delete(ues).where(eq(ues.id, idUE)).returning(); + const existing = await db.select().from(ues).where(eq(ues.id, idUE)); - if (result.length === 0) { + if (existing.length === 0) { return new Response( JSON.stringify({ error: "Ressource introuvable" }), { @@ -113,6 +118,12 @@ export const handler: Handlers = { ); } + 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); diff --git a/routes/(apps)/admin/api/users/[id].ts b/routes/(apps)/admin/api/users/[id].ts index 236156c..ae064d0 100644 --- a/routes/(apps)/admin/api/users/[id].ts +++ b/routes/(apps)/admin/api/users/[id].ts @@ -1,13 +1,14 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { users } from "$root/databases/schema.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" } }, -); +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} @@ -21,7 +22,7 @@ export const handler: Handlers = { .where(eq(users.id, context.params.id)) .then((rows) => rows[0] ?? null); - if (!user) return NOT_FOUND; + if (!user) return NOT_FOUND(); return new Response(JSON.stringify(user), { headers: { "content-type": "application/json" }, @@ -42,7 +43,7 @@ export const handler: Handlers = { .where(eq(users.id, context.params.id)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -50,16 +51,25 @@ export const handler: Handlers = { }, // #64 DELETE /users/{id} + // Cascade: deletes enseignements for this user. async DELETE( _request: Request, context: FreshContext, ): Promise { - const [deleted] = await db - .delete(users) - .where(eq(users.id, context.params.id)) - .returning(); + const id = context.params.id; - if (!deleted) return NOT_FOUND; + 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/partials/import-maquette.tsx b/routes/(apps)/admin/partials/import-maquette.tsx new file mode 100644 index 0000000..74f1985 --- /dev/null +++ b/routes/(apps)/admin/partials/import-maquette.tsx @@ -0,0 +1,23 @@ +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 const config = getPartialsConfig(); +export default makePartials(ImportMaquettePage); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/admin/partials/promotions.tsx similarity index 86% rename from routes/(apps)/students/partials/(admin)/promotions.tsx rename to routes/(apps)/admin/partials/promotions.tsx index 003f993..bf6b622 100644 --- a/routes/(apps)/students/partials/(admin)/promotions.tsx +++ b/routes/(apps)/admin/partials/promotions.tsx @@ -4,7 +4,7 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import AdminPromotions from "../../(_islands)/AdminPromotions.tsx"; +import AdminPromotions from "../(_islands)/AdminPromotions.tsx"; // deno-lint-ignore require-await async function Promotions( diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/admin/partials/ues.tsx similarity index 88% rename from routes/(apps)/notes/partials/(admin)/ues.tsx rename to routes/(apps)/admin/partials/ues.tsx index 2d6b0e9..4f69270 100644 --- a/routes/(apps)/notes/partials/(admin)/ues.tsx +++ b/routes/(apps)/admin/partials/ues.tsx @@ -4,7 +4,7 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import AdminUEs from "../../(_islands)/AdminUEs.tsx"; +import AdminUEs from "../(_islands)/AdminUEs.tsx"; // deno-lint-ignore require-await async function UEs( diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 4114c11..2490029 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -1,15 +1,61 @@ // @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 { useRef } from "preact/hooks"; +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 success = 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)) { @@ -18,76 +64,404 @@ export default function ImportNotes() { } file.value = f; error.value = null; - success.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 onDragOver(e: DragEvent) { - e.preventDefault(); - dragging.value = true; + 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 onDragLeave() { - dragging.value = false; + function onSheetChange(name: string) { + selectedSheet.value = name; + if (workbookRef.current) { + parseSheet(workbookRef.current, name); + } } - 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); + 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 (!file.value) return; + if (!workbookRef.current || !selectedSheet.value) return; uploading.value = true; error.value = null; - success.value = null; + importResult.value = null; try { - const arrayBuffer = await file.value.arrayBuffer(); - const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let imported = 0; - let failed = 0; + const sheet = workbookRef.current.Sheets[selectedSheet.value]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); - for (const sheetName of workbook.SheetNames) { - const sheet = workbook.Sheets[sheetName]; - const rows = XLSX.utils.sheet_to_json<{ - numEtud: number; - idModule: string; - note: number; - }>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 }); + const moduleCols = columns.value.filter((c) => c.type === "module"); - for (const row of rows) { - const res = await fetch("/notes/api/notes", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(row), + 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`, }); - if (res.ok) imported++; - else failed++; + 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`, + }); + } } } - success.value = `Import terminé — ${imported} ajouté${ - imported !== 1 ? "s" : "" - }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; + importResult.value = { added, modified, ignored, errors, details }; } catch { - error.value = "Erreur lors de la lecture du fichier."; + error.value = "Erreur lors de l'import."; } finally { uploading.value = false; } } function downloadTemplate() { - const wb = XLSX.utils.book_new(); - const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]); - XLSX.utils.book_append_sheet(wb, ws, "Notes"); - XLSX.writeFile(wb, "modele_notes.xlsx"); + 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("/admin/api/modules").then((r) => r.json()), + fetch("/admin/api/ue-modules").then((r) => r.json()), + fetch("/admin/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 ( @@ -97,14 +471,25 @@ export default function ImportNotes() { type="file" accept=".xlsx,.xls" style="display:none" - onChange={onInputChange} + onChange={(e) => { + 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()} > @@ -117,10 +502,85 @@ export default function ImportNotes() {
{error.value &&

{error.value}

} - {success.value && ( -

- {success.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 = module (importe) | UE = moyenne UE (ignore) | X = malus +

+
)}
@@ -128,22 +588,31 @@ export default function ImportNotes() { type="button" class="btn btn-primary" onClick={doImport} - disabled={!file.value || uploading.value} + disabled={!file.value || uploading.value || + columns.value.filter((c) => c.type === "module").length === 0} > - {uploading.value ? "…" : "⊕ Importer"} + {uploading.value ? "..." : "+ Importer"} +

- Format : numEtud | idModule |{" "} - note + Format : Nom | Prenom |{" "} + CODE - Module (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 index f72bc89..af24da8 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -14,8 +14,18 @@ type UEModule = { coeff: number; }; type Module = { id: string; nom: string }; -type Note = { numEtud: number; idModule: string; note: number }; -type Ajustement = { numEtud: number; idUE: number; valeur: number }; +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 }; @@ -27,31 +37,38 @@ 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 [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; value: string } | null + { idModule: string; field: "note" | "noteSession2"; value: string } | null >(null); - const [ajustInputs, setAjustInputs] = useState>({}); + const [ajustInputs, setAjustInputs] = useState< + Record + >({}); async function load() { try { const sRes = await fetch(`/students/api/students/${numEtud}`); - if (!sRes.ok) throw new Error("Élève introuvable"); + 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("/admin/api/ues"), fetch( - `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), fetch("/admin/api/modules"), fetch(`/notes/api/notes?numEtud=${numEtud}`), @@ -66,13 +83,18 @@ export default function NoteRecap({ numEtud }: Props) { } if (notesRes.ok) { const ns: Note[] = await notesRes.json(); - setNoteMap(new Map(ns.map((n) => [n.idModule, n.note]))); + 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] = String(a.valeur); + const inputs: Record = {}; + for (const a of aj) { + inputs[a.idUE] = { + valeur: String(a.valeur), + malus: String(a.malus), + }; + } setAjustInputs(inputs); } } catch (e) { @@ -87,57 +109,108 @@ export default function NoteRecap({ numEtud }: Props) { }, [numEtud]); function calcAvg(ueMods: UEModule[]): number | null { - let total = 0, coeff = 0; + let total = 0, + coeff = 0; for (const um of ueMods) { const n = noteMap.get(um.idModule); if (n === undefined) return null; - total += n * um.coeff; + const val = effectiveNote(n); + total += val * um.coeff; coeff += um.coeff; } return coeff > 0 ? total / coeff : null; } - async function saveNote(idModule: string, value: string) { + 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 res = await fetch( - `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, - { - method: "PUT", + + 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({ note }), - }, - ); - if (res.ok) { - const updated: Note = await res.json(); - setNoteMap((prev) => new Map(prev).set(idModule, updated.note)); + 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 val = parseFloat((ajustInputs[idUE] ?? "").replace(",", ".")); + 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 }), + 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 }), + 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.map((a) => (a.idUE === idUE ? updated : a)) : [...prev, updated] ); } @@ -160,7 +233,7 @@ export default function NoteRecap({ numEtud }: Props) { if (loading) { return (
-

Chargement…

+

Chargement...

); } @@ -180,19 +253,21 @@ export default function NoteRecap({ numEtud }: Props) { href="/notes/courses" f-partial="/notes/partials/courses" > - ← Retour à la liste + ← Retour a la liste

- Récap notes – {student.prenom} {student.nom} + Recap notes – {student.prenom} {student.nom}

{student.numEtud} - {student.prenom} {student.nom} + + {student.prenom} {student.nom} + {student.idPromo}
@@ -201,7 +276,7 @@ export default function NoteRecap({ numEtud }: Props) { {ueList.length === 0 ? (

- Aucune UE configurée pour cette promotion. + Aucune UE configuree pour cette promotion.

) : ueList.map((ue) => { @@ -209,14 +284,26 @@ export default function NoteRecap({ numEtud }: Props) { 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. calculée : {fmt(avg)} + + Moy. calculee : {fmt(avg)} )} {ajust && ( @@ -224,7 +311,15 @@ export default function NoteRecap({ numEtud }: Props) { class="note-chip note-chip--ajust" style="font-size: 0.78rem" > - ⚡ Ajust. actif : {fmt(ajust.valeur)} + Ajust. actif : {fmt(ajust.valeur)} + + )} + {ajust && ajust.malus > 0 && ( + + Malus : -{ajust.malus} )}
@@ -236,21 +331,22 @@ export default function NoteRecap({ numEtud }: Props) { class="col-dim" style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem" > - Aucun module associé à cette UE pour cette promotion. + Aucun module associe a cette UE pour cette promotion.

) : (
{ueMods.map((um) => { - const noteVal = noteMap.get(um.idModule); + 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; - const isEditing = editingNote?.idModule === um.idModule; return ( -
+
{um.idModule} @@ -260,17 +356,20 @@ export default function NoteRecap({ numEtud }: Props) { coef {um.coeff} - {isEditing + + {/* Session 1 note */} + {editingNote?.idModule === um.idModule && + editingNote.field === "note" ? (
setEditingNote({ - idModule: um.idModule, + ...editingNote, value: (e.target as HTMLInputElement).value, })} @@ -278,7 +377,8 @@ export default function NoteRecap({ numEtud }: Props) { if (e.key === "Enter") { saveNote( um.idModule, - editingNote!.value, + "note", + editingNote.value, ); } if (e.key === "Escape") { @@ -286,7 +386,11 @@ export default function NoteRecap({ numEtud }: Props) { } }} onBlur={() => - saveNote(um.idModule, editingNote!.value)} + saveNote( + um.idModule, + "note", + editingNote.value, + )} /> setEditingNote({ idModule: um.idModule, + field: "note", value: noteVal !== undefined ? String(noteVal) : "", })} > + S1:{" "} {noteVal !== undefined ? fmt(noteVal) : "—/20"} )} -
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + field: "noteSession2", + value: noteS2 != null ? String(noteS2) : "", + })} + > + S2: {noteS2 != null ? fmt(noteS2) : "—"} + + )} + + {/* Effective note indicator */} + {noteS2 != null && ( + - - {" "} - note - + → {fmt(effective!)} + + )}
); })}
)} - {/* Ajustement */} + {/* Ajustement + Malus */}

Ajustement de la moyenne UE

- Override ponctuel – laisser vide pour utiliser la moy. - calculée + La valeur remplace la moyenne calculee. Le malus est + soustrait.

+ + Val: + setAjustInputs((prev) => ({ ...prev, - [ue.id]: (e.target as HTMLInputElement).value, + [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 && ( <> @@ -380,14 +561,19 @@ export default function NoteRecap({ numEtud }: Props) { class="btn btn-sm btn-secondary" onClick={() => resetAjust(ue.id)} > - ✕ Réinitialiser + Reinitialiser - Affiché à l'élève : {fmt(ajust.valeur)} - {avg !== null ? ` (calculée : ${fmt(avg)})` : ""} + 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 index fd77b87..6dcbf7e 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "preact/hooks"; -type Note = { numEtud: number; idModule: string; note: number }; +type Note = { + numEtud: number; + idModule: string; + note: number; + noteSession2: number | null; +}; type UE = { id: number; nom: string }; type UEModule = { idModule: string; @@ -9,7 +14,12 @@ type UEModule = { coeff: number; }; type Module = { id: string; nom: string }; -type Ajustement = { numEtud: number; idUE: number; valeur: number }; +type Ajustement = { + numEtud: number; + idUE: number; + valeur: number; + malus: number; +}; type Props = { numEtud: number | null; @@ -26,6 +36,11 @@ function avgClass(avg: number | null): string { 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([]); @@ -47,8 +62,8 @@ export default function NotesView({ numEtud, prenom }: Props) { 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("/admin/api/ues"), + fetch("/admin/api/ue-modules"), fetch("/admin/api/modules"), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); @@ -72,7 +87,6 @@ export default function NotesView({ numEtud, prenom }: Props) { setModules(modData); setAjustements(ajData); - // Derive promos from UE-modules for this student's notes const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule)); const relevantPromos = [ ...new Set( @@ -99,7 +113,7 @@ export default function NotesView({ numEtud, prenom }: Props) {

Bonjour {prenom}{" "} - — aucun dossier étudiant n'est associé à votre compte. + — aucun dossier etudiant n'est associe a votre compte.

); @@ -108,7 +122,7 @@ export default function NotesView({ numEtud, prenom }: Props) { if (loading) { return (
-

Chargement…

+

Chargement...

); } @@ -121,20 +135,18 @@ export default function NotesView({ numEtud, prenom }: Props) { ); } - // Filter UE-modules by active promo const filteredUeModules = activePromo ? ueModules.filter((um) => um.idPromo === activePromo) : ueModules; - // Group UE-modules by UE 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.note]), + notes.map((n) => [n.idModule, n]), ); const ajMap = Object.fromEntries( - ajustements.map((a) => [a.idUE, a.valeur]), + ajustements.map((a) => [a.idUE, a]), ); return ( @@ -155,7 +167,7 @@ export default function NotesView({ numEtud, prenom }: Props) { )} {ueIds.length === 0 && ( -

Aucune note disponible pour cette période.

+

Aucune note disponible pour cette periode.

)} {ueIds.map((ueId) => { @@ -166,51 +178,65 @@ export default function NotesView({ numEtud, prenom }: Props) { let weightedSum = 0; let coveredCoeff = 0; ueModsForUE.forEach((um) => { - const note = noteMap[um.idModule]; - if (note !== undefined) { - weightedSum += note * um.coeff; + 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 ajustement = ajMap[ueId] ?? null; - const finalAvg = avg !== null && ajustement !== null - ? avg + ajustement - : avg; + 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 - {ajustement !== null && ajustement !== 0 && ( - - {" "} - (ajustement : {ajustement > 0 ? "+" : ""} - {ajustement}) - - )} -

- )} - {finalAvg === null && ( -

Notes non disponibles

- )} + {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 note = noteMap[um.idModule] ?? null; + 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 : "Module inconnu"} (coef {um.coeff}) - - {note !== null ? `${note}/20` : "—"} + + {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 2e4dc98..38f1625 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -7,10 +7,9 @@ const properties: AppProperties = { index: "Accueil", notes: "Mes notes", courses: "Consulter", - ues: "UEs", - import: "Import xlsx", + import: "Import Notes", }, - adminOnly: ["courses", "ues", "import"], + adminOnly: ["courses", "import"], studentOnly: ["notes"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/api/ajustements.ts b/routes/(apps)/notes/api/ajustements.ts index 6239fb2..b40e61e 100644 --- a/routes/(apps)/notes/api/ajustements.ts +++ b/routes/(apps)/notes/api/ajustements.ts @@ -52,8 +52,12 @@ export const handler: Handlers = { } try { - const body: { numEtud: number; idUE: number; valeur: number } = - await request.json(); + const body: { + numEtud: number; + idUE: number; + valeur: number; + malus?: number; + } = await request.json(); if (!body.numEtud || !body.idUE || body.valeur === undefined) { return new Response( @@ -62,12 +66,23 @@ export const handler: Handlers = { ); } + 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(); diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index a165f44..b527cdc 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -4,12 +4,13 @@ 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 NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ajustement introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #50 GET /ajustements/{numEtud}/{idUE} @@ -18,7 +19,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -34,7 +35,7 @@ export const handler: Handlers = { .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .then((rows) => rows[0] ?? null); - if (!ajustement) return NOT_FOUND; + if (!ajustement) return NOT_FOUND(); return new Response(JSON.stringify(ajustement), { headers: { "content-type": "application/json" }, @@ -47,7 +48,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -57,7 +58,7 @@ export const handler: Handlers = { return new Response("Paramètres invalides", { status: 400 }); } - const body: { valeur: number } = await request.json(); + const body: { valeur: number; malus?: number } = await request.json(); if (body.valeur === undefined) { return new Response(JSON.stringify({ error: "Champ requis: valeur" }), { @@ -66,13 +67,28 @@ export const handler: Handlers = { }); } + 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({ valeur: body.valeur }) + .set(set) .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -85,7 +101,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -100,7 +116,7 @@ export const handler: Handlers = { .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index b7fd580..498d007 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -41,7 +41,7 @@ export const handler: Handlers = { async POST(request) { try { const body = await request.json(); - const { note, numEtud, idModule } = body; + const { note, numEtud, idModule, noteSession2 } = body; if (note === undefined || !numEtud || !idModule) { return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { @@ -55,7 +55,32 @@ export const handler: Handlers = { }); } - const result = await db.insert(notes).values({ note, numEtud, idModule }) + 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]), { diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts index 8618366..544e56a 100644 --- a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -64,13 +64,39 @@ export const handler: Handlers = { } const body = await request.json(); - const { note } = body; + const { note, noteSession2 } = body; - if (note === undefined) { - return new Response("Champ 'note' manquant", { status: 400 }); + if (note === undefined && noteSession2 === undefined) { + return new Response("Au moins 'note' ou 'noteSession2' requis", { + status: 400, + }); } - const result = await db.update(notes).set({ note }).where( + 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), diff --git a/routes/(apps)/notes/api/notes/import-xlsx.ts b/routes/(apps)/notes/api/notes/import-xlsx.ts index b31079b..7b01333 100644 --- a/routes/(apps)/notes/api/notes/import-xlsx.ts +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -26,20 +26,38 @@ export const handler: Handlers = { const rows = XLSX.utils.sheet_to_json(sheet) as { numEtud: number; note: number; + noteSession2?: number; }[]; for (const row of rows) { - const { numEtud, note } = row; + 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({ numEtud, idModule, note }) + .values(values) .onConflictDoUpdate({ target: [notes.numEtud, notes.idModule], - set: { note }, + set, }); } diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 188a05e..ec2e5d8 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -6,31 +6,52 @@ import { getPartialsConfig, makePartials, } from "$root/defaults/makePartials.tsx"; -import { State } from "$root/defaults/interfaces.ts"; +import { CasContent, State } from "$root/defaults/interfaces.ts"; import NotesView from "../(_islands)/NotesView.tsx"; async function Notes( _request: Request, context: FreshContext, ) { - const session = - (context.state as unknown as { session: { sn: string; givenName: string } }) - .session; - const { sn, givenName } = session; + const session = (context.state as unknown as { session: CasContent }).session; let numEtud: number | null = null; try { - const student = await db - .select() - .from(students) - .where(and(eq(students.nom, sn), eq(students.prenom, givenName))) - .then((rows) => rows[0] ?? null); - numEtud = student?.numEtud ?? null; + 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 ; + return ( + + ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index c55ae51..86132e9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -15,6 +15,9 @@ export default function ConsultStudents() { 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 { @@ -44,6 +47,11 @@ export default function ConsultStudents() { }); 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"); } @@ -56,6 +64,85 @@ export default function ConsultStudents() { 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 (

Gestion des Élèves

@@ -93,6 +180,44 @@ export default function ConsultStudents() { />
+ {/* Bulk actions bar */} + {selectedInView.length > 0 && ( +
+ + {selectedInView.length} sélectionné(s) + +
+ + + +
+
+ )} + {loading ?

Chargement…

: ( @@ -100,6 +225,13 @@ export default function ConsultStudents() { + @@ -111,13 +243,23 @@ export default function ConsultStudents() { {filtered.length === 0 ? ( - ) : filtered.map((s) => ( - + + diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index bf751d5..2a20255 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -2,13 +2,17 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; import { useRef } from "preact/hooks"; import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; export default function UploadStudents() { const file = useSignal(null); const dragging = useSignal(false); const uploading = useSignal(false); const error = useSignal(null); - const success = useSignal(null); + const importResult = useSignal(null); const inputRef = useRef(null); function pickFile(f: File) { @@ -18,7 +22,7 @@ export default function UploadStudents() { } file.value = f; error.value = null; - success.value = null; + importResult.value = null; } function onDragOver(e: DragEvent) { @@ -46,36 +50,58 @@ export default function UploadStudents() { if (!file.value) return; uploading.value = true; error.value = null; - success.value = null; + importResult.value = null; try { const arrayBuffer = await file.value.arrayBuffer(); const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let imported = 0; - let failed = 0; + let added = 0; + let errors = 0; + const details: ImportDetail[] = []; for (const sheetName of workbook.SheetNames) { const sheet = workbook.Sheets[sheetName]; const rows = XLSX.utils.sheet_to_json<{ - numEtud: number; nom: string; prenom: string; - }>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 }); + numEtud: number; + idPromo: string; + }>(sheet, { + header: ["nom", "prenom", "numEtud", "idPromo"], + range: 2, + }); for (const row of rows) { const res = await fetch("/students/api/students", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...row, idPromo: sheetName }), + body: JSON.stringify(row), }); - if (res.ok) imported++; - else failed++; + 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"}`, + }); + } } } - success.value = `Import terminé — ${imported} ajouté${ - imported !== 1 ? "s" : "" - }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; + importResult.value = { + added, + modified: 0, + ignored: 0, + errors, + details, + }; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -84,10 +110,7 @@ export default function UploadStudents() { } function downloadTemplate() { - const wb = XLSX.utils.book_new(); - const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]); - XLSX.utils.book_append_sheet(wb, ws, "4A22"); - XLSX.writeFile(wb, "modele_etudiants.xlsx"); + globalThis.open("/templates/modele_etudiants.xlsx", "_blank"); } return ( @@ -117,10 +140,12 @@ export default function UploadStudents() { {error.value &&

{error.value}

} - {success.value && ( -

- {success.value} -

+ + {importResult.value && ( + (importResult.value = null)} + /> )}
@@ -142,9 +167,8 @@ export default function UploadStudents() {

- Format : promo (nom de la feuille) |{" "} - numEtud | nom |{" "} - prénom + Format : Nom | Prenom |{" "} + Numero-etudiant | Promotion

); diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 5483732..d6b498c 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -6,10 +6,9 @@ const properties: AppProperties = { pages: { index: "Accueil", consult: "Élèves", - promotions: "Promotions", upload: "Import xlsx", }, - adminOnly: ["consult", "promotions", "upload"], + adminOnly: ["consult", "upload"], hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/api/promotions/[idPromo].ts b/routes/(apps)/students/api/promotions/[idPromo].ts index a206d3a..53f1d95 100644 --- a/routes/(apps)/students/api/promotions/[idPromo].ts +++ b/routes/(apps)/students/api/promotions/[idPromo].ts @@ -1,15 +1,25 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { promotions } from "$root/databases/schema.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 NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #15 GET /promotions/{idPromo} @@ -18,7 +28,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const promo = await db @@ -27,7 +37,7 @@ export const handler: Handlers = { .where(eq(promotions.id, context.params.idPromo)) .then((rows) => rows[0] ?? null); - if (!promo) return NOT_FOUND; + if (!promo) return NOT_FOUND(); return new Response(JSON.stringify(promo), { headers: { "content-type": "application/json" }, @@ -40,7 +50,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const body: { annee: string } = await request.json(); @@ -51,7 +61,7 @@ export const handler: Handlers = { .where(eq(promotions.id, context.params.idPromo)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -59,20 +69,104 @@ export const handler: Handlers = { }, // #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; + return FORBIDDEN(); } - const [deleted] = await db - .delete(promotions) - .where(eq(promotions.id, context.params.idPromo)) - .returning(); + const idPromo = context.params.idPromo; - if (!deleted) return NOT_FOUND; + 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 65ed62d..e2e5d38 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -44,13 +44,25 @@ export const handler: Handlers = { idPromo: string; } = await request.json(); - if (!body.nom || !body.prenom || !body.idPromo) { + if (!body.nom || !body.prenom) { return new Response(null, { status: 400 }); } + 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 [created] = await db .insert(students) - .values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .values(values) .returning(); return new Response(JSON.stringify(created), { diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts index 3d92371..ce0f2d3 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -1,15 +1,21 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { students } from "$root/databases/schema.ts"; +import { + ajustements, + mobility, + notes, + 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 NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #10 GET /students/{numEtud} @@ -18,7 +24,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -28,7 +34,7 @@ export const handler: Handlers = { .where(eq(students.numEtud, numEtud)) .then((rows) => rows[0] ?? null); - if (!student) return NOT_FOUND; + if (!student) return NOT_FOUND(); return new Response(JSON.stringify(student), { headers: { "content-type": "application/json" }, @@ -41,20 +47,32 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); - const body: { nom: string; prenom: string; idPromo: string } = await request - .json(); + 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({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .set(set) .where(eq(students.numEtud, numEtud)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -62,21 +80,31 @@ export const handler: Handlers = { }, // #12 DELETE /students/{numEtud} + // Cascade: deletes notes, ajustements, mobility for this student. async DELETE( _request: Request, context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); - const [deleted] = await db - .delete(students) - .where(eq(students.numEtud, numEtud)) - .returning(); - if (!deleted) return NOT_FOUND; + 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(mobility).where(eq(mobility.studentId, numEtud)); + await tx.delete(students).where(eq(students.numEtud, numEtud)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/dev-login.ts b/routes/dev-login.ts index b50898e..22058a7 100644 --- a/routes/dev-login.ts +++ b/routes/dev-login.ts @@ -4,41 +4,73 @@ import { createJwt } from "@popov/jwt"; import { setCookie } from "$std/http/cookie.ts"; import { getKey } from "$root/routes/_middleware.ts"; -const FAKE_ADMIN: CasContent = { - 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", -}; +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) { + 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: FAKE_ADMIN, + user, }; - const token = await createJwt(payload, getKey(FAKE_ADMIN.uid)); + const token = await createJwt(payload, getKey(user.uid)); const headers = new Headers(); setCookie(headers, { name: "sessionToken", value: token }); headers.set("Location", "/apps"); diff --git a/routes/login.tsx b/routes/login.tsx index 3b1da1e..dd35867 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -45,6 +45,8 @@ function createUserJWT(casResponse: CasResponse): Promise { } }); + console.log(fullUserInfos); + const now = Math.floor(Date.now() / 1000); const payload: LoginJWT = { iss: "PolyMPR", diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts new file mode 100644 index 0000000..ab2f3bc --- /dev/null +++ b/scripts/generate-templates.ts @@ -0,0 +1,60 @@ +// @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..0dd3dce --- /dev/null +++ b/scripts/inspect-maquette.ts @@ -0,0 +1,25 @@ +// @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/static/styles/ui.css b/static/styles/ui.css index a4efd9a..9d2218e 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -391,6 +391,54 @@ 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 ------------------------------------------------------- */ @@ -852,6 +900,14 @@ 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; @@ -1008,3 +1064,140 @@ 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 0000000000000000000000000000000000000000..65ddb687fa311ad737d2224daf70456e79a59f31 GIT binary patch literal 16207 zcmeHOTZklA8J?IZvJzrYK_WuYpa^lMr)PR*XU6FXbIopcXSbd1Y%U`4bk*tZnyFi= zs(Yprg=8ZV3@U0AeGq&QqQpxu5%Umz_rX_v^JQ~EqwI?af)9TGIdwbL)jgMOlxSw@ zt*TT1|DXT-xAUKK{;FTP{f-Y$#eYA#?aQzJ>OE6a^t%U*M^^bUpIbrs(De^mu6r)q6H~!h$Zu_=7IzA&4@+!^2vML?W7k zvba=8Hl=bgGjo$Qs|2=B2iX@E8} zos#A-8zy{J7^v11ieFoeh;Li411AiqLAU${IS*Gb;2LbjIH=Gi7zCS^ul1P)+ch1- zJ#6wL*t5lg5HD(_S@A~}TuzXza0G4!Z%6z^coBkeFem!-JFTSC%1(9p{8mC=09fHL z-1heN=5{5aCjgkPW2BOoEY0f9Qe`8inE{fw!?ZVN7iOy}e1`t~5YNjS8ygGD5igRc z0Ft-WA^4@)#p?Q;0xy6h{K61?bz^;Vb5Vg8KoWj&2!7}M@?uqm7eHOhbPk4~NufJC z5%mP5?OGQK!IuEOG#i1ZP_Tx?x~Pk`>x83h&1U9FEWX19Ek zQ3q^=v&~n-~8(jp3?sE@(@C*H!>`UDw)h{Xzj;qP{W`ZuXo`ap!(7 z3%AEuzo_p`<|wM&?s$5GjbmLyg|K2b>p-?h(^ez8kph zPfv;!bwu2Cw?r_W1whbNxML1V|j5kC35<|QyRQs+q zuvJJuG!KesUS_VFh1_xvHoMIBieMY2)6K$O3=UwTOsl&{Hin&a*Ht})o}mZkj8ykE zlQPLPZX6#79yB`&OR0g-o1S05*8SX7?6)zn+Kh`RgOIL7YTIB13+?GAO8@6ku zbxdMK&T}CbtVhAU14Hx{>p67}uNb!ecO&$>5xVq0AE8J2^5o-k8j2C6DIt-$w#dBg zk%@QPw5$fL$u9>Y_YN=t89QhsJh839lZBYq#VvUR27qPWPT_YJ| z7RM%5yatZdLQ;+IC#<4q)N&wQW^kz@r8y(biqKtbLPO)D;Yk}goir1vL1@P_AdVvYf1MS&nvCD=ja~EtE8!d9_j-D_z{$9+VbPF37A7HbwQ&msWeM zFA673N4#8YRTXSrjBpsmjXggIw^-1XIux%WRap-8B>A6PsFJZwW&j1jsIJduB)TO-v_Y*XO*Buq3SS7-E%Z>X9U^h>)3SZx0)0K7bympv@T3t zM3X2C2)Mlcsn3xIwK|leWc^KvWXaSiec(TxxJYNlpmMnc*>5cWghe4OHF1&n0zIB1 zvjd?QX_i!ty+WD%mY1Wh&d9<^Cg99mDi02k!=Agyl5u-85XFTi!Y4jiFKhxJO zF3gxF)TXR$`Lb{MVA&FCD0ZnLRXbIwc z(V{^`jNl1X&Xx)C-0{c>hUgg~J3$s3YDENaJ{ARTTG|0#23a|3a;{iXmUAm2I|1{> zA}d_^oK$3Tp*U7iXAoI?1M_Rft+1zpPgMYu{`A6@V znxfyG@;EpUhun0SeD;H2L8C#()hDlY2t1!NLsS*0r@^9HVx^D3dA6bk!`17)>jl@E zIFt)upH9!-vBN;q-JS!gq(IrE$qUs|EG(8ZS&6af8rTl_ze2C`Y;NyC&4(m}}@WDnwkT?dK>2?OLs)UdXS zSqQ1Vvx5hk+~}S}SSvXwZV^L}aK}Jn-WpV}Y!v+p!ezohzb)4{P)w6mglt+{wRoG% z7Fk`FuA%GU|CSquu8kYRWF6OG7H-QiMsx$RI*+oehuaG6qjpBYn14wKq-(kbL(#3I zCm)D@mYSq}%Vf2qcGMa3JZo^JXSX;Gkg4t`(yizf{bUBC3?*SIs(iRY$T*rx2)BBs zg*2;R7@%n!rGgPte8^fB7YS+73xs0G$*h^Ytd-LHU2d2?dpUusn|)VKX|+;%FQKYJ z_i0eb1)=?`Kl$l>w@*#c@1rsZ71kM@Jsu_GCKis6Pn<0p<*T?19EUqpPp0|2jboO| z%GNxIO9Vn3nH@r3$-Nerq|u91x5@h$b_)eUa31%Na!iVEI^ArcIt&Jevq-e`*ANtK zmx|IfkM}~2hd#oIiCr)h;RKrDnjscx9OtmG4Jx_OwvVvD=l*KBPi}!9I!JnV%sx`&LXc>L1h5`Z6%%Pz3m#X^rkq6rjkJg_=X76qYU=4{;c)c3OFCV) zknHTf7E_s+s~uTBf^^Q??6|;SM#qdGYZ=S=6rysz?3;R5mdnXB;>Z{-OFJnoV}L2w z)R~V7t+aMd(^liLip(N2jp1GHZ4{>;zPz-NND#nidb&nAPJrhQKp?q9e{Jw7|2>aK{uIDtG~}+;pu**KA7@!E%&Evx~$O z4V$hl>gWV576h;xajU!Br@AE&qDf{K#f9`x7UL}|0~Sr~UfAS~x#Fug|yVXZ6{+Wqtlj zLIIK%kfGL`Av+KX_ZrnCCMZd^t;~C-B z684zN#GH0w^JC0e-G_e}{E8?IZFbGzp{P1LnJnYUFS0=_&ihoXD<(&pE6FuhOx%b% zX-e(Ht2jtfrFTx;5A5Il&&#L^r{4zwIj&8|jCfLpj;~FJgL22<#(0*HUXSAl7BM-FRGw4$;B$i5hGSmF zR%rjS@v+~{!QklkK^R>k-)JyrpP4g3QA~zIlWKu!sH2ia+J&;8_$qxJOCwCSl%TFP zXe;4?#%ej+xOxHm1t^iLGfaKR#KM(6cHpo`4$LPofx-S;EZ|bXeTMQSak@ZY@RLqr;6?=`0+JEDP+4r6A-*5D+O9L7wg?lzh+~*z z%CX>q63mz#8PA6J6GKK%Iojn^*}0(hhi`rH?ng0y(C_Z7eUXRX+AN}bD?}viT23b` zI*yA^8HM6RV>s?eLo@V*q}TH;@wJShmwEJcK)EtgDPuWIAKI)KI#w-q`oT>WLA=R8 z+Gd#jekKcQ=<;O(eH0*DiN5vfpJIG&6Z;WZXFWzH8+`uOR3T`bd^T4;G}0v>(ne0x(jt-2Xlf+{z&Ik5=`E)40=yP|K{67r?-(<7FwKvUam@&q zp8*2%2QxN9$?~>Ct6hN5cPPd5`(ybc`f3=RmLU{jTzqIX1N<%d(aVSqi)teeo=VkuxB3cjtVG>ceZ z$5D)+^f&zu@!rT0ui~pnpEi$ZZj(uRIiLBQs<)iiC`l+3h=P9?`b%BA$`wB4&WihEEi?|I-gVEnFBbCVfjsBWBPbdFrXiDU7=nr)A zMw};*KTJ#s{7VxAQlreH=8phU)TcioP!mVXet4XxlHb!zsr=(pf=X6fdF1@wN{amA zrxO<9&%yJ^`JI9k`JNeN-yn~eU+txc-m>jH literal 0 HcmV?d00001 diff --git a/static/templates/modele_maquette.xlsx b/static/templates/modele_maquette.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f326c5e2752f48b637cc079bd2e03e6d08d7e22c GIT binary patch literal 17926 zcmeHP-EU;cRritw!JsIt2ogdJF@#@#yjH~Yr1E469h~3z17`! ze82YI+ig1mvDpAx5rH5q2p*7lKm>#!D}XN^ksK8Gh`%EQR^z5PmUZM|Y}&-5+Z8|_yf_q(gx6(fq7XEDe3_s!`zY-ojp@r8pP&9ouk7pb6eqwJ|K zj(N;17PDLmHz`Tws4B+RJW-5&{ZntA{?kvdtkCDjrLpU(yuOvQT|rCQ_byw;3ayZx z!Fyas8lcN;uVQ%2g$X|t25R(0$2a#9;vENe;KdOYm`>Ou=iv$lT!Zaer#1SDE~1tb z8dK)Lc5Tn{&-(lv_Uy1IM#X+*UHp*+S2H9ho`YM!>x#bwFF~+QHw4dcG{~6Nx{aON z?To$vaN=3GPN&oA)G~SkfSD#*DrG6sY;?D4hbhehkbIn_-CEyVZ>aDE`t=!^cMcB^ zH+K?QBvAn*9~(39+v{76gAD~<07>}G8TiKGL94Z;zzZM=zcmBjy}h&5P~ip8*s;CS z8EERzZa1Nxfed};ZYlUSz_-^E@Ei)(kXaY882Vm3&(>V_)DOD=AV7-Io)IGm519!o zZZUTd+Kd>mUCs(MvdD~PiinpHnV}sZ;=U9xctlR`qj&%Mqj$e)eDv&Cx)^H0C`)*qC^Bedm=KKPrjzW?{XUk(Tl z$^k$8!?(Ztt+zk?qu=@Gzj~_#{2*k5B6#0+d1O4`XT~GnMcag8RLS`uyc$M-%xndN zjX}GFbSEAwfDbO1QwDd)RpmVi5j@HO?@XR5vi8Q|B(_U9-y6FM;IZ#JhkjV9fP2KN zpyB?+8|D4iWEk70Wi;LO*8g#Y{y0Kc{?A9~dA@w}aXAmg zh|-jh$Xr`w-tOGQduTgOkLKj}BawSY7=WxU3JFgvtMF_h7L4&oo(u*G+$dxM#|ZuS zWjh}C#*7wFYGN8WiW1VJ$OwF_*{qA=Qrg56xqmEYo0~Kbk&z=}@v(2o;yM+l(~XRb zkZL66v?-%H5m=U4{dPhWZMzP$8VR*Dx0(=51Vx2g)?`w#n3qH4S<_IMX%m@^fqV#B z-onxru|_h+9F9e-bPk+$3u!fdzTqxPS}hwgW)_!Sq!bslSrdJin$S%9sCm*xuBOdQ zYf#$dLI|^%Z$C>aIVF%PR&f11p|1~LKBWl<3;gFkgv9k*$q&xYVL_&WO^RPFz&WwRG2_G?ZKXgj%vFr#7 z7Q&(F1*>bMZBeDh)+Evt*R%wjEN4zIW=XW9)tS`eB)BluOa}2*5D#rDm0dLM>P;b! zzp17a&G`RWQ)oEA3PRnY9x$=E8#04DWxwKw@z_Tc3dXkC4KePE*a2(73N5Okd~BqM z?~qUFTl$=&A>0}h!T6CqGC~`}NjwfYKS5X$)^`c9S~B!Vl1@TMSX4pWD3D?g_>?<+ zih)}cgDb`urY@pM5(WgEZhz`~;z5HEOT`v zXiH68B)&jT$H-zss3Omj8mU((gWt|h!s?nLEHeNX##$M%W?HvCVf?d0)8WF5X+Z7!j}Ti8Y(Gf-8L;YAifXVR3Xt+?fV@W*N@F5YQng8PN?Jl@C3GQOI!NwQ`7`-;lWk|>p(;)^$_>_o$gu4UO=RikHh7e&!9orj2y5gT2_y(oY1 z6dm2hs{h21yHinsH{l1tNhqvq;9D2TqZ7#iS6Us-VF=Rk9`T`JTj%NgBh!R*A-O}6 zrpN-C(8$m-#CqAJK}Ag93001k8S=*E$Qg#XFkiSOimORcGNXg7N0LxS)wT@Z`Saz!x=J6y?P@4V$hM6E=*S2 z4u1R}&%gDB=T}zf^GUfK9EnYC+DyLwMzEmKqwVVD&pJdw$eATh6{x$x;6oK=4 zhZ-#3JPG|EdeXf|eem0FR^NZ;tN1cx*?26!Km6l2zxkpZ+ zNJ9n9z37Y5>1$$Z)$|=dG(;aJlVsW@mk=#%6(oz*Lu_0)7qVF3NyC%{(m}}@BnR<@ zRR@X(nE>QPRM6bRD1=ns>*9q5H+m)&?pHh!6!d1cm-+>=m zI80OQ2wBwJbNG|uM8F1=fgq= zWBes0P+!v{1WFzyJ^4cPi_|3TXC`Z$4-;lA2%L)t6L-L|flTLqlD-vO!Kcs|r74+E z$;pQYgp94JjPPJ$J4mxingN>Dd9D~S#K&ylaFLK^T%Z(74rcwVa=(&4A9Kr|xH}ot zi9Pk@khWjRpCwd7^nD%_v>^1m-~QS4fB%t{75e<73__*njIJM#GIBQ-j*w4WFB<8m zxGZdkdvu=62>B4(EVbRETAlPIA|dw7&d_1mvyq-jql&z9v-%XajT1uf9{-GzObT!& zJsjY87%U8%ktmrqkG$B9Ck{8P2_{T|;2cE~dv9Z3k zh8OzEkgH&vai@49xo;@k+gu)Tpr&I_)Og&PE5{ub^MQ{e6_ENf3`?>vj`<|yQ8{m% z{=(>i{6ZYXzyK5Ra7xL!6bRLcI_7{)%gaT}Z}o8S2&ydb7E}*&s-$EZJ|g4bcu8>l&f92H{&2Ni?7?5u7$RGH%iWWoa!}k0`!d zUVJoDtcbjwiHx(i1!a=R8sD}CAw|}Jxkz}fF1Z$0+B%4o zT!ZQYs#J1WJS%y5Ii(>)rT{M`mnXkvhGCCzhK5_6F!V#@9>rJ6wUO5LVgfjb=-e%( zB1ZI@mgrQASRj(0JJ<5WUw(b%%Wr;SWraSUl6hkB?DO^0!;}gQ!^<7azhts{?u3|E zXbGa5o{3vCF^Q4K@2fhY+U;s+n`3#xTh4sXt=W^*%hIwgFiW&e7Ge%oY2Gr7y|k@j zGF+H>p-!t!rWUWoyFI2KT!`KcaIl&mI9TsQSBsY? z%$acPoZ}q}|0A)rZbX4&$6Z_O0iXi;V#{}WV|yrV1S_a4nGi`#QPA>Tar9fzVo?OU z5s#+NLpo{;LNqTr#(8*psfzYil?IEV;Uq@NNMNL9t(p0vfYiv~lyHP{V8OJ4pS(g_ zC@?0Wy}>H>w!L|KwQkmj^>uT7W5=xjs^|bo3&`l!-K2XC zsC#IQg5w|`vc+LrcAIF@5SnpV5Ygs1P1bVuRq#(=_vj^)4zpq*HYWN?TM1fBkF&-v zXl`gyID%4>jTgZcj?9ooGIR$NiS4kOkP%?%P&;HMY(5jYq3aJ6Ivt@EaK)QQf1EdgFA4k6{oT+yjJuZ zKl5uZH(+q|`7sz>rr)G7*B_e;VR7lvS#6cPSyZZfIl$iF(((b;^Dx}fRljDBeE20y z(2Iq8cd^KgbKNHlgCEkXcz23zBdqL3_7^dP!Ttv<;%qmYVwN-0twW?UN@}gbK#EQF{-xDn&h?)YVYsbZF zpG8p1l$9Cud4!B5Y1i4=+1goMh=EM5R(<98zSuQltF}iMSxs8!S8ovh#XmlM{U?5S zWraR;gYXwxSVqS^wO7e#qhEZst`IbqAJLWjv8I$`l{68hN<5G9M0{>zbKom!rn#4F z_Zl4v?DA4D`nh5xDxucwR}cczm{6r!4B{nt1G@M)7jNoWYi_j4&yjVl38!BJ0t+wJ zT!x!A9go)N0YcX&%jr)S%0=|mF?Ll3Q3P^vKX?uJZFyI74g)!5S=fTb99-l|&SwRg z@+J)2KvNM8q2qQmm6u}#bS_a5Nn%iWImXBymSRwOIR4{){1UIwdGpC_6dAj>Cz%%i5({~f)e2T z`ut}TM|_CO{Gp8nQySf5qF&W=pDS_GyhfScCyq-_x&(hYv=^`0-<%+n+X?!Y$RW0y zi?{5bMFc_Y5{CKVgR6D&VzrAC4U0Hu>1vuxT#b8bV%l_LDUPsSkzqfJW=nG0MZ96) zX`b@TtV;|$U0X;+sER=yS;TXg9L)dO4WtrD>?En)iV*&Uop{< z^|gW=`5zj}%7Kok&-Ze~_U+l(pN_5P@;UaO?-a08XVlU43^YftzF3-%p4|JvA0nVz N`1d6k#iT`V{|$?G$*%wa literal 0 HcmV?d00001 diff --git a/static/templates/modele_notes.xlsx b/static/templates/modele_notes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..55d96146bc9f7adaee89a5a6e21a9fa7b9372121 GIT binary patch literal 16337 zcmeHO-ESn>Rritwp+zCA00|)>m0BVQMRvLEw#PQ^4tBe}vpeG%%kG`s1VOl6b-TO9 z^|e*iZ95^-W`od*2qePtkq0Cm5J5t~N(o{mmPh^s-~ox3{fHo-ec=Vf1HW@_eckHn zw#Q?E0_#yvRoy!0+;h)8U-#Zq$M-(*?2j#Fe}DAMufFq_-?y|xzn?_og=>Bxl5T9C z2hrIe2+me6Ja>g!c0Y?dV=ANA3H)}Wxw6_Yq;CbbLtcfT6q^1iVe>|2l0Z|c+ zWYTVsNVq9B`O+lWG|YNt)^3xT1VWMpBm8Lc{|k9ozfB(EfhIk+XfL;g$gQUv>Kjr)v7gS^w`VQc%& zK~A3m++-H6*XwnA&72+s2+P7q6)$C)t;4P6enztdq;6+vcUL!7TRMD+{`w5h+xz?b z8`~)_iYNoA+tv*H*6LvlIacm`7Nn=|l-ceXcMIy?i7UB^G0 zfu;#PJWQ$QAj81DTMNDg@U7Jpynuo>g97KlzAVBibzLCHQ4}}FS z?h0=ZIf6P6J5rS1s3t3(X`;T2*ovGG9`~hCz#jmEHyTdUEwE4L427Q^e#A)vSxIj za~w^sM~)Q*aWG7b&pF1vaB4-;cLtgw6-aj-5A$+aH)c$=CYz&2#(v<|jru@Nbx0V5 z;ABOab8cdN>1twpK}-_i)sgHAWILsaxi6A&9h0Y*kyVE4#R+D5B;CO1*)opna2`e& zZ;AFj0e@9H?MLpVr$a`Gb5=+5z6gRcjfET`g?lm4uxbd1DCFOYR~Z( zxoy{h-&Q?*?6%vDJp{2jO@2S-+&e}9vJcTnc)YD5^MzPA#w}SgI?&)okzgDn z3X)fxWPCanw1Lv(W#lMMNsnS946tXj%FVU3sZ-?siCS%L&^knUj#wl|fvuX?sX1G2 ztp+j{wp3H3HJ7B>MdaTvwPfr!Uy4*69&RjyAxDgM_sTBK~Z zGk_Xray>5Ax1b{bYg|A_^r@A@p|q0nWS3e`?)YFvrv=~mc3pX5#)LYW-OlENkoK-dFfuBn3VQjU$^WdI* zC}i=sHI%X%|34cFg#+v$tlKmL7B+VyVUVS?8$pze19+iu>{y2p;y(KhXbW~|@fyO% zK?+|ZIi;(zIYoob8VkYX#2FcpgK(0JBPoyJmRS3)Al6Jnk0R-&gjl0W?8c#DdmyLM z?UN7OBp=)`#!z+kCaD`>xT^l?.sMwFwJ?M?G!dFWI&@IRfnC}YN;D!BydZzlho zMV6MHxF~!^&thbGAoQZhl3JNnXu)rLJMHR*ELf49d_SSE^CU{|PP4_^6m}|MP1dSyuBb7yRTD+iFk6T4#PC5^`7A0J zJjO&HVAp@*s?(`B#1q>=cpR~I4FdZzy>(+1aFy248ipWU|3nTA$G*tcAGsuy38@p3 zEJbE$LL)`X5!dS$4LV{9PpB$d=E!STBj*_0GhB9#%o}QT1aLl2=Ef7ePm9I;(8u zIQZp%fA+1;;Fu4&=`i``2f<9^l#Z*fU+WNukrXyp73inITrIIPh2y+g zQG*>=$59Z*kNP;2i=m(X#5?qo*sy|$52`eQs>x6ns-@W2Y#6E%qZ`=R4*2wIlOXxI z_uu*T4}RlK^Zj?ehKr%v#$$E;@EdQa8_!eM58nJWbrZ+x`kilnJ;h;bNZIJ@l2K^f zP(x=o{-QGaCLgU@fg3~y&tbYqrc-hS(Zx|gx>-HM!G(LNni)?irfN_IO4^`$;0tyg z$RFekkQGrwXBSZjslI=R2MTURi44<_C~;sBZIe$u(+UeQl!FxpUZrqarX z2ZW5Hshsd&;7>Fzic;(`y**P6K^|*I(DXk z3Tf>|aj&3SJoklDs5l{i=kNaUuRpQ0M8BUHa#yiv7^%f@lI zPxWL*B!@U=Y3}q`TA0E)9_qeILw)e@DCdFS@9Xc6PkJ*TT_QH^G^=c-%^NEl zI>}!8qLnvmthI91S{iEyFCRaA(9dxccRI*Ry}buIK$oaZJ6n3|fog58>dnw69>`{i zW5t@P;Zw@ujBpw%?OqfG5!9&sNL~{T>NFvMy_hPfNUMzaHC<)RG7@MM1%wrY|K-=0 zzVarFoqnHE245~>ySbfbRBp}8FWd+LqGIV@R@|^+AsVSVO0$~2H1C*^V~tg*o=RaZ z?AemI>(a6>5$+vJL(VIT`QorEE_UZnjf zuVqcQ?W`cYW zWX`WCRFie=IJn_5ZOSY&%)HRNBJr0VKmQ;1QIt-X+&nm!{36H0nYen*nW&Zht#1k8bHJ2isAIt~^FVoH#!l|iW?1A=qq7QmvzYISU;F#7|H|{9M--vo^JNtyBY(PB zVud?Ir07}&l^qKQ)>qrT=7_R%W7hR3U9+v6WH^aj{$9tnOqqV4U^Z8pCKlxMsn3pW zVe`Z*KKUsl@S75(=OpDXc*>x*rC#RH7cp{{bX@1>=PT#y%m#9~rupj8eck{R%Z^Xq zBw4g&qkjzYC;#yH^&kJir6u~&#~{DZ#f}I*Irsu;ZTyQ*Hx*3d`Y~OTy`}hACP|=j zSqbqXE>iKigDrrcNHU$>bm!jak!M%mccI&ck&1*y({8{AW=_GRw}|33cmw)|WiH;- zw^zJ)SzaLTT4AeS0fLAwS3H5D>7Gy9EdZe}Ve08m7xG2)bx*piVgo!mf9kaY`~&sb z&72O@qGjO-7Wd#GTXH|cWa@`7@WP6SP)~>t-Kf1DBcu`&jU|Oa?e!QVXIP6t?e!Sa ztH-!lrZ@FC=W}uBO+C)zuzoC$Lpg6S{HkvJ3viM-0zmsb&iGYbHw$n=yDGJ^nb6yM zDPSiIwyjNR8L>u>Qy)PYaPcMNXHr9ai0?8Z2Sr>I-c+h?R+FD6>aAim+5$f{Tr$#Y z^si!j`HRWhV}z1Brhf$=d~#6!VDf2LU}9HvSezq0*(5I(J0DIh>N(F>vt;7Qde2m> zvfj8>kI-IDv7bh=HOcLwzMl(LFiJ%lJwzw*dt0CCHh&W zQl70Ua&?ELK>nHKoQ3Rz^eS?7v!Fo!#)`IeP(`dR_zJ`yZq6?Ms@Txcffnfh*{aP)kMDi|4-n9E`1d6!#m~3t>AwN5j07eC literal 0 HcmV?d00001 diff --git a/tests/e2e/robustness_test.ts b/tests/e2e/robustness_test.ts index fb5552b..ced5ac4 100644 --- a/tests/e2e/robustness_test.ts +++ b/tests/e2e/robustness_test.ts @@ -21,8 +21,8 @@ import { 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/notes/api/ues.ts"; -import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.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"; diff --git a/tests/e2e/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts index 3a921f8..30dba17 100644 --- a/tests/e2e/ue_modules_test.ts +++ b/tests/e2e/ue_modules_test.ts @@ -14,8 +14,8 @@ import { seedUes, truncateAll, } from "../helpers/db_integration.ts"; -import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts"; -import { handler as ueModuleHandler } from "$apps/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].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"; diff --git a/tests/e2e/ues_test.ts b/tests/e2e/ues_test.ts index 1797f8d..d5d726d 100644 --- a/tests/e2e/ues_test.ts +++ b/tests/e2e/ues_test.ts @@ -7,8 +7,8 @@ import { makeJsonRequest, } from "../helpers/handler.ts"; import { seedUes, truncateAll } from "../helpers/db_integration.ts"; -import { handler as uesHandler } from "$apps/notes/api/ues.ts"; -import { handler as ueHandler } from "$apps/notes/api/ues/[idUE].ts"; +import { handler as uesHandler } from "$apps/admin/api/ues.ts"; +import { handler as ueHandler } from "$apps/admin/api/ues/[idUE].ts"; // --- GET /ues --- -- 2.52.0 From 9a4c6863d1ff65486ceebfc9586ff9c702ddc417 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 12:47:23 +0200 Subject: [PATCH 096/103] feat: stages module, mobility frontend, theme toggle, employeeOnly access control - Add stages module with full CRUD API and admin overview island - Add mobility overview island (Liste, Kanban, Detail CRUD views) - Add contract PDF upload/download endpoints for mobilites - Add light/dark theme toggle in header - Add employeeOnly flag to hide entire modules from students (admin, students, stages) - Add read-only GET endpoints for modules/ues/ue-modules in notes module - Add [slug].tsx catch-all routes for direct URL navigation - Replace old mobility table with mobilites + stages schema (migration 0004) - Allow students to create mobilites and upload contracts - Redirect authenticated users from / to /apps catalog --- compose.prod.yml | 3 + .../0004_add_stages_and_mobilites.sql | 28 + databases/migrations/meta/_journal.json | 7 + databases/schema.ts | 36 +- defaults/interfaces.ts | 1 + defaults/makeSlug.ts | 36 + fresh.gen.ts | 57 +- routes/(_components)/Header.tsx | 8 + routes/(apps)/_middleware.ts | 8 +- .../admin/(_islands)/ImportMaquette.tsx | 48 +- routes/(apps)/admin/(_props)/props.ts | 12 +- routes/(apps)/admin/[slug].tsx | 2 + .../(apps)/admin/partials/enseignements.tsx | 1 + .../(apps)/admin/partials/import-maquette.tsx | 1 + routes/(apps)/admin/partials/modules.tsx | 1 + routes/(apps)/admin/partials/permissions.tsx | 1 + routes/(apps)/admin/partials/promotions.tsx | 1 + routes/(apps)/admin/partials/roles.tsx | 1 + routes/(apps)/admin/partials/ues.tsx | 1 + routes/(apps)/admin/partials/users.tsx | 1 + .../mobility/(_islands)/ConsultMobility.tsx | 115 --- .../(_islands)/ConsultStudents_test.tsx | 75 -- .../mobility/(_islands)/EditMobility.tsx | 248 ----- .../(apps)/mobility/(_islands)/ImportFile.tsx | 0 .../mobility/(_islands)/MobilityOverview.tsx | 931 ++++++++++++++++++ routes/(apps)/mobility/(_props)/props.ts | 12 +- routes/(apps)/mobility/[slug].tsx | 2 + routes/(apps)/mobility/api/insert_mobility.ts | 122 --- routes/(apps)/mobility/api/mobilites.ts | 116 +++ .../(apps)/mobility/api/mobilites/[idMob].ts | 149 +++ .../mobility/api/mobilites/[idMob]/contrat.ts | 156 +++ .../(admin)/consult_students_test.tsx | 21 - .../partials/(admin)/edit_mobility.tsx | 20 - routes/(apps)/mobility/partials/index.tsx | 22 +- routes/(apps)/mobility/partials/overview.tsx | 19 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 15 +- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 6 +- routes/(apps)/notes/(_islands)/NotesView.tsx | 6 +- routes/(apps)/notes/[slug].tsx | 2 + routes/(apps)/notes/api/modules.ts | 12 + routes/(apps)/notes/api/ue-modules.ts | 28 + routes/(apps)/notes/api/ues.ts | 12 + .../(apps)/notes/partials/(admin)/courses.tsx | 1 + .../(apps)/notes/partials/(admin)/import.tsx | 1 + routes/(apps)/notes/partials/notes.tsx | 1 + .../stages/(_islands)/StagesOverview.tsx | 542 ++++++++++ routes/(apps)/stages/(_props)/props.ts | 15 + routes/(apps)/stages/[slug].tsx | 2 + routes/(apps)/stages/api/stages.ts | 84 ++ routes/(apps)/stages/api/stages/[idStage].ts | 122 +++ routes/(apps)/stages/index.tsx | 2 + routes/(apps)/stages/partials/index.tsx | 30 + routes/(apps)/stages/partials/overview.tsx | 19 + routes/(apps)/students/(_props)/props.ts | 1 + routes/(apps)/students/[slug].tsx | 2 + .../(apps)/students/api/students/[numEtud].ts | 8 +- .../students/partials/(admin)/consult.tsx | 1 + .../students/partials/(admin)/upload.tsx | 1 + routes/_app.tsx | 1 + routes/apps.tsx | 13 +- routes/index.tsx | 21 +- scripts/generate-templates.ts | 29 +- scripts/inspect-maquette.ts | 8 +- static/theme.js | 29 + tests/helpers/db_integration.ts | 2 +- 65 files changed, 2597 insertions(+), 681 deletions(-) create mode 100644 databases/migrations/0004_add_stages_and_mobilites.sql create mode 100644 defaults/makeSlug.ts create mode 100644 routes/(apps)/admin/[slug].tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/EditMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ImportFile.tsx create mode 100644 routes/(apps)/mobility/(_islands)/MobilityOverview.tsx create mode 100644 routes/(apps)/mobility/[slug].tsx delete mode 100644 routes/(apps)/mobility/api/insert_mobility.ts create mode 100644 routes/(apps)/mobility/api/mobilites.ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob].ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts delete mode 100644 routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx delete mode 100644 routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx create mode 100644 routes/(apps)/notes/[slug].tsx create mode 100644 routes/(apps)/notes/api/modules.ts create mode 100644 routes/(apps)/notes/api/ue-modules.ts create mode 100644 routes/(apps)/notes/api/ues.ts create mode 100644 routes/(apps)/stages/(_islands)/StagesOverview.tsx create mode 100644 routes/(apps)/stages/(_props)/props.ts create mode 100644 routes/(apps)/stages/[slug].tsx create mode 100644 routes/(apps)/stages/api/stages.ts create mode 100644 routes/(apps)/stages/api/stages/[idStage].ts create mode 100644 routes/(apps)/stages/index.tsx create mode 100644 routes/(apps)/stages/partials/index.tsx create mode 100644 routes/(apps)/stages/partials/overview.tsx create mode 100644 routes/(apps)/students/[slug].tsx create mode 100644 static/theme.js diff --git a/compose.prod.yml b/compose.prod.yml index 6d7f11a..a20b1e8 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -30,9 +30,12 @@ services: 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/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/_journal.json b/databases/migrations/meta/_journal.json index f81c27d..3cb93bd 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "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.ts b/databases/schema.ts index 9bf678d..eadbb3a 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,7 +1,7 @@ import { - date, doublePrecision, integer, + pgEnum, pgTable, primaryKey, serial, @@ -89,13 +89,29 @@ export const ajustements = pgTable("ajustements", { 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"), +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/interfaces.ts b/defaults/interfaces.ts index 9b65a28..951201a 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -20,6 +20,7 @@ export interface AppProperties { 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..ee12fa4 --- /dev/null +++ b/defaults/makeSlug.ts @@ -0,0 +1,36 @@ +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 + } + } + + if (!page) { + return context.renderNotFound(); + } + + return page(request, context); + }; +} diff --git a/fresh.gen.ts b/fresh.gen.ts index bd47e97..4d3229d 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,6 +4,7 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.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"; @@ -30,16 +31,22 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol 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_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; +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_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"; @@ -47,6 +54,13 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti 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_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"; @@ -79,13 +93,12 @@ import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_island 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_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_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"; @@ -95,6 +108,7 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./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": @@ -131,23 +145,29 @@ const manifest = { "./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/api/insert_mobility.ts": - $_apps_mobility_api_insert_mobility, + "./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)/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, @@ -158,6 +178,15 @@ const manifest = { "./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)/students/[slug].tsx": $_apps_students_slug_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -210,12 +239,8 @@ const manifest = { $_apps_admin_islands_EditUser, "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": $_apps_admin_islands_ImportMaquette, - "./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)/mobility/(_islands)/MobilityOverview.tsx": + $_apps_mobility_islands_MobilityOverview, "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": $_apps_notes_islands_AdminConsultNotes, "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": @@ -224,6 +249,8 @@ const manifest = { $_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/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 ece0de4..a671134 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,11 +21,17 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = { ...properties.pages }; 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) => diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 676e283..278081c 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -151,7 +151,10 @@ export default function ImportMaquette() { }); if (res.ok) { const created = await res.json(); - promos.value = [...promos.value, { id: created.id, annee: created.annee }]; + promos.value = [...promos.value, { + id: created.id, + annee: created.annee, + }]; newPromoId.value = ""; newPromoAnnee.value = ""; } else { @@ -289,7 +292,14 @@ export default function ImportMaquette() { ); const data: (string | number | null)[][] = [ - ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\nECTS", + "Coeff.", + ], ]; for (const ue of uesData) { @@ -303,7 +313,14 @@ export default function ImportMaquette() { 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([ + null, + um.idModule, + null, + mod ? mod.nom : um.idModule, + null, + um.coeff, + ]); } data.push([]); } @@ -312,7 +329,10 @@ export default function ImportMaquette() { 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 blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -384,8 +404,9 @@ export default function ImportMaquette() { class="filter-select" placeholder="ID (ex: 3AFISE24-25)" value={newPromoId.value} - onInput={(e) => - (newPromoId.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoId.value = (e.target as HTMLInputElement).value)} style="min-width: 10rem" /> - (newPromoAnnee.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)} style="min-width: 8rem" />
+ 0} + onChange={toggleAll} + /> + N° étud. Nom Prénom
+ Aucun élève trouvé
+ toggleOne(s.numEtud)} + /> + {s.numEtud} {s.nom} {s.prenom}
- - - - - - - - - - - - - - - {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)/ImportFile.tsx b/routes/(apps)/mobility/(_islands)/ImportFile.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx new file mode 100644 index 0000000..f429469 --- /dev/null +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -0,0 +1,931 @@ +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() { + 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])), + ); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + // 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={() => { + setDetailStudent(null); + setEditingMob(null); + setShowAddForm(false); + }} + 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" + ? ( + setDetailStudent(s)} + /> + ) + : ( + setDetailStudent(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 + ? ( + 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, onCancel, onSave }: { + numEtud: number; + ecoles: string[]; + paysList: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState("4"); + const [ecole, setEcole] = useState(""); + const [pays, setPays] = useState(""); + const [status, setStatus] = useState("contracts_received"); + const [busy, setBusy] = useState(false); + + 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: ecole || null, + pays: pays || null, + status, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la création"); + } finally { + setBusy(false); + } + } + + return ( +
+

Nouvelle mobilité

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+
+
+ + +
+
+ ); +} + +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..3efac01 100644 --- a/routes/(apps)/mobility/(_props)/props.ts +++ b/routes/(apps)/mobility/(_props)/props.ts @@ -3,14 +3,14 @@ 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é", }, - adminOnly: ["edit_mobility", "consult_students_test"], + adminOnly: ["overview"], + studentOnly: ["my-mobility"], }; 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 a6e9aa9..0000000 --- a/routes/(apps)/mobility/api/insert_mobility.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Handlers } from "$fresh/server.ts"; -import { db } from "$root/databases/db.ts"; -import { mobility, promotions, students } from "$root/databases/schema.ts"; -import { eq } from "npm:drizzle-orm@0.45.2"; - -export const handler: Handlers = { - async GET() { - try { - const studentRows = await db - .select({ - id: students.userId, - firstName: students.firstName, - lastName: students.lastName, - promotionId: students.promotionId, - endyear: promotions.endyear, - current: promotions.current, - }) - .from(students) - .leftJoin(promotions, eq(students.promotionId, promotions.id)); - - const mobilityRows = await db.select().from(mobility); - - const promotionRows = await db - .select({ - id: promotions.id, - endyear: promotions.endyear, - current: promotions.current, - }) - .from(promotions); - - return new Response( - JSON.stringify({ - mobilities: mobilityRows, - students: studentRows, - promotions: promotionRows, - }), - { 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) { - try { - const body = await request.json(); - const { data } = body; - - if (!Array.isArray(data)) { - throw new Error("Invalid request body"); - } - - for (const entry of data) { - const { - id, - studentId, - startDate, - endDate, - weeksCount, - destinationCountry, - destinationName, - mobilityStatus = "N/A", - } = entry; - - const studentExists = await db - .select({ userId: students.userId }) - .from(students) - .where(eq(students.userId, studentId)) - .limit(1) - .then((rows) => rows.length > 0); - - if (!studentExists) { - 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); - calculatedWeeksCount = start <= end - ? Math.ceil( - (end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000), - ) - : null; - } - - await db - .insert(mobility) - .values({ - id, - studentId, - startDate, - endDate, - weeksCount: calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - }) - .onConflictDoUpdate({ - target: mobility.id, - set: { - startDate, - endDate, - weeksCount: calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - }, - }); - } - - 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)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 2490029..a582797 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -273,9 +273,9 @@ export default function ImportNotes() { Promise.all([ fetch("/students/api/students").then((r) => r.json()), fetch("/notes/api/notes").then((r) => r.json()), - fetch("/admin/api/modules").then((r) => r.json()), - fetch("/admin/api/ue-modules").then((r) => r.json()), - fetch("/admin/api/ues").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, @@ -450,7 +450,10 @@ export default function ImportNotes() { 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 blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -600,6 +603,8 @@ export default function ImportNotes() { > Telecharger Modele + { + /* TODO: fix blob download in Fresh + */ + }

diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index af24da8..de9ec39 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -66,11 +66,11 @@ export default function NoteRecap({ numEtud }: Props) { setStudent(s); const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ - fetch("/admin/api/ues"), + fetch("/notes/api/ues"), fetch( - `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), - fetch("/admin/api/modules"), + fetch("/notes/api/modules"), fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx index 6dcbf7e..326d6e7 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -62,9 +62,9 @@ export default function NotesView({ numEtud, prenom }: Props) { try { const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ fetch(`/notes/api/notes?numEtud=${numEtud}`), - fetch("/admin/api/ues"), - fetch("/admin/api/ue-modules"), - fetch("/admin/api/modules"), + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/notes/api/modules"), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); 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/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/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/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 0ec8ebe..6f9f8ba 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -11,5 +11,6 @@ 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 index 111edf0..3f56e2d 100644 --- a/routes/(apps)/notes/partials/(admin)/import.tsx +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -19,5 +19,6 @@ async function ImportNotesPage( ); } +export { ImportNotesPage as Page }; export const config = getPartialsConfig(); export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index ec2e5d8..de9e686 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -54,5 +54,6 @@ async function Notes( ); } +export { Notes as Page }; export const config = getPartialsConfig(); export default makePartials(Notes); diff --git a/routes/(apps)/stages/(_islands)/StagesOverview.tsx b/routes/(apps)/stages/(_islands)/StagesOverview.tsx new file mode 100644 index 0000000..60b0a7e --- /dev/null +++ b/routes/(apps)/stages/(_islands)/StagesOverview.tsx @@ -0,0 +1,542 @@ +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() { + 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); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + if (detailStudent) { + return ( + s.numEtud === detailStudent.numEtud)} + allStages={stagesList} + editingStage={editingStage} + setEditingStage={setEditingStage} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={() => { + setDetailStudent(null); + setEditingStage(null); + setShowAddForm(false); + }} + 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)} + /> +
+ + setDetailStudent(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)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index d6b498c..9a503f6 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -9,6 +9,7 @@ const properties: AppProperties = { upload: "Import xlsx", }, 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/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts index ce0f2d3..6d2c0e6 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -2,8 +2,9 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { ajustements, - mobility, + mobilites, notes, + stages, students, } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; @@ -80,7 +81,7 @@ export const handler: Handlers = { }, // #12 DELETE /students/{numEtud} - // Cascade: deletes notes, ajustements, mobility for this student. + // Cascade: deletes notes, ajustements, mobilites, stages for this student. async DELETE( _request: Request, context: FreshContext, @@ -102,7 +103,8 @@ export const handler: Handlers = { 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(mobility).where(eq(mobility.studentId, 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)); }); diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index 4c81c71..2adaaa4 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -11,5 +11,6 @@ async function Students(_request: Request, _context: FreshContext) { 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 578d830..ca1b847 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -16,5 +16,6 @@ async function Students(_request: Request, _context: FreshContext) { ); } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/_app.tsx b/routes/_app.tsx index 81187c3..77ba7c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -29,6 +29,7 @@ export default async function App( +
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/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 index ab2f3bc..5245a7c 100644 --- a/scripts/generate-templates.ts +++ b/scripts/generate-templates.ts @@ -5,7 +5,12 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { const wb = XLSX.utils.book_new(); const ws = XLSX.utils.aoa_to_sheet([ - [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"], + [ + null, + null, + null, + "Promotion peut etre vide mais doit prealablement Exister", + ], ["Nom", "Prenom", "Numero-etudiant", "Promotion"], ["NOM", "PRENOM", 12345678, "3AFISE24-25"], ]); @@ -38,8 +43,26 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { 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"], + [ + "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], diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts index 0dd3dce..b96865f 100644 --- a/scripts/inspect-maquette.ts +++ b/scripts/inspect-maquette.ts @@ -9,7 +9,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { 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 }); + 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]; @@ -17,7 +19,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { 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(" | "); + const preview = row.slice(0, 6).map((c) => + c != null ? String(c).substring(0, 25) : "" + ).join(" | "); console.log(` [${i}] ${preview}`); } } diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..af947af --- /dev/null +++ b/static/theme.js @@ -0,0 +1,29 @@ +(function () { + var t = localStorage.getItem("theme"); + if (t) document.documentElement.style.colorScheme = t; + + document.addEventListener("click", function (e) { + var btn = e.target.closest("#theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + var 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 () { + var btn = document.getElementById("theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var 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/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 2a571bf..be102db 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -26,7 +26,7 @@ export const testPool = createTestPool(); export const testDb = drizzle(testPool, { schema }); const ALL_TABLES = - '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; + '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre. -- 2.52.0 From b6586f7715bca67c5a36e7d4e5d979d349039e95 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:14:33 +0200 Subject: [PATCH 097/103] feat: made stuff --- .gitea/workflows/test.yml | 4 + defaults/makeSlug.ts | 26 + openapi.yml | 1536 +++++++++++++++++ .../admin/(_islands)/AdminEnseignements.tsx | 12 +- .../(apps)/admin/(_islands)/AdminModules.tsx | 18 +- routes/(apps)/admin/(_islands)/AdminUEs.tsx | 18 +- routes/(apps)/admin/(_islands)/EditModule.tsx | 12 +- .../admin/(_islands)/ImportMaquette.tsx | 2 +- .../mobility/(_islands)/MobilityOverview.tsx | 162 +- .../mobility/{[slug].tsx => [...slug].tsx} | 0 .../mobility/partials/overview/[numEtud].tsx | 20 + .../(apps)/notes/(_islands)/ImportNotes.tsx | 2 +- .../stages/(_islands)/StagesOverview.tsx | 30 +- .../stages/{[slug].tsx => [...slug].tsx} | 0 .../stages/partials/overview/[numEtud].tsx | 20 + .../students/(_islands)/EditStudents.tsx | 92 +- static/styles/ui.css | 12 +- static/theme.js | 16 +- tests/e2e/modules_test.ts | 4 +- 19 files changed, 1870 insertions(+), 116 deletions(-) create mode 100644 openapi.yml rename routes/(apps)/mobility/{[slug].tsx => [...slug].tsx} (100%) create mode 100644 routes/(apps)/mobility/partials/overview/[numEtud].tsx rename routes/(apps)/stages/{[slug].tsx => [...slug].tsx} (100%) create mode 100644 routes/(apps)/stages/partials/overview/[numEtud].tsx diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index d2a8d16..259baf7 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -56,6 +56,10 @@ jobs: 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 diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts index ee12fa4..d1110fe 100644 --- a/defaults/makeSlug.ts +++ b/defaults/makeSlug.ts @@ -27,6 +27,32 @@ export default function makeSlug(basePath: string): Route { } } + // 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(); } 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/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx index 2a0c2af..3b76991 100644 --- a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -115,7 +115,7 @@ export default function AdminEnseignements() { return (
-

Assignations Enseignant → Module / Promo

+

Assignations Enseignant → ECUE / Promo

{error &&

{error}

} @@ -135,7 +135,7 @@ export default function AdminEnseignements() { onChange={(e) => setFilterModule((e.target as HTMLSelectElement).value)} > - + {modules.map((m) => ( ))} @@ -194,7 +194,7 @@ export default function AdminEnseignements() {
- + setNewNom((e.target as HTMLInputElement).value)} /> diff --git a/routes/(apps)/admin/(_islands)/AdminUEs.tsx b/routes/(apps)/admin/(_islands)/AdminUEs.tsx index c8612c2..5bb7a57 100644 --- a/routes/(apps)/admin/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/admin/(_islands)/AdminUEs.tsx @@ -104,7 +104,7 @@ export default function AdminUEs() { idUE: number, idPromo: string, ) { - if (!confirm("Supprimer ce module de la UE ?")) return; + if (!confirm("Supprimer cet ECUE de la UE ?")) return; try { const res = await fetch( `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ @@ -121,7 +121,7 @@ export default function AdminUEs() { async function addUeModule() { if (!selectedUe || !addModuleId || !addPromoId) { - setAddError("Module et Promo sont requis"); + setAddError("ECUE et Promo sont requis"); return; } const coeff = parseFloat(addCoeff); @@ -203,7 +203,7 @@ export default function AdminUEs() { class="col-dim" style="font-size: 0.78rem; margin: -0.5rem 0 1rem" > - UE = Unité d'Enseignement regroupant plusieurs modules + UE = Unité d'Enseignement regroupant plusieurs ECUEs

{error &&

{error}

} @@ -314,13 +314,13 @@ export default function AdminUEs() {

{selectedUe.nom}

- Modules assignés (UE_Module) + ECUEs assignés (UE_Module)

- + @@ -331,7 +331,7 @@ export default function AdminUEs() { ? ( ) @@ -441,7 +441,7 @@ export default function AdminUEs() {

- Ajouter un module à cette UE + Ajouter un ECUE à cette UE

{addError && (

@@ -458,7 +458,7 @@ export default function AdminUEs() { )} style="min-width: 12rem" > - + {modules.map((m) => (

- Sélectionnez une UE pour voir ses modules + Sélectionnez une UE pour voir ses ECUEs

)} diff --git a/routes/(apps)/admin/(_islands)/EditModule.tsx b/routes/(apps)/admin/(_islands)/EditModule.tsx index a9770ba..1bd554e 100644 --- a/routes/(apps)/admin/(_islands)/EditModule.tsx +++ b/routes/(apps)/admin/(_islands)/EditModule.tsx @@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) { fetch("/admin/api/users"), fetch("/students/api/promotions"), ]); - if (!mRes.ok) throw new Error("Module introuvable"); + if (!mRes.ok) throw new Error("ECUE introuvable"); const m: Module = await mRes.json(); setMod(m); setNom(m.nom); @@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) { if (!res.ok) throw new Error("Modification échouée"); const updated: Module = await res.json(); setMod(updated); - setSaveMsg("Module enregistré."); + setSaveMsg("ECUE enregistré."); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) { } async function deleteModule() { - if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return; + if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return; try { const res = await fetch( `/admin/api/modules/${encodeURIComponent(moduleId)}`, @@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) { class="page-title" style="border-bottom: none; margin-bottom: 0.5rem" > - Module -- {mod.id} + ECUE -- {mod.id}
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) { />
- + - Supprimer le module + Supprimer l'ECUE
diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 278081c..9e9dc33 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -281,7 +281,7 @@ export default function ImportMaquette() { globalThis.open("/templates/modele_maquette.xlsx", "_blank"); } - function downloadExport() { + function _downloadExport() { Promise.all([ fetch("/admin/api/ues").then((r) => r.json()), fetch("/admin/api/ue-modules").then((r) => r.json()), diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx index f429469..a167414 100644 --- a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -67,7 +67,9 @@ function validatedWeeks(mobs: Mobilite[]): number { .reduce((sum, m) => sum + m.duree, 0); } -export default function MobilityOverview() { +export default function MobilityOverview( + { initialNumEtud }: { initialNumEtud?: number } = {}, +) { const [students, setStudents] = useState([]); const [promos, setPromos] = useState([]); const [mobilites, setMobilites] = useState([]); @@ -105,6 +107,12 @@ export default function MobilityOverview() { 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 { @@ -116,6 +124,18 @@ export default function MobilityOverview() { 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 ( @@ -128,11 +148,7 @@ export default function MobilityOverview() { setEditingMob={setEditingMob} showAddForm={showAddForm} setShowAddForm={setShowAddForm} - onBack={() => { - setDetailStudent(null); - setEditingMob(null); - setShowAddForm(false); - }} + onBack={closeStudent} onReload={load} /> ); @@ -207,14 +223,14 @@ export default function MobilityOverview() { setDetailStudent(s)} + onConsult={(s) => openStudent(s)} /> ) : ( setDetailStudent(s)} + onConsult={(s) => openStudent(s)} /> )} @@ -637,6 +653,9 @@ function DetailView( numEtud={student.numEtud} ecoles={ecoles} paysList={paysList} + availableStages={Object.values(stagesMap) + .filter((s) => s.numEtud === student.numEtud) + .filter((s) => !mobilites.some((m) => m.idStage === s.id))} onCancel={() => setShowAddForm(false)} onSave={async () => { setShowAddForm(false); @@ -774,10 +793,11 @@ function MobEditForm( } function MobAddForm( - { numEtud, ecoles, paysList, onCancel, onSave }: { + { numEtud, ecoles, paysList, availableStages, onCancel, onSave }: { numEtud: number; ecoles: string[]; paysList: string[]; + availableStages: Stage[]; onCancel: () => void; onSave: () => Promise; }, @@ -786,8 +806,19 @@ function MobAddForm( 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 { @@ -797,9 +828,10 @@ function MobAddForm( body: JSON.stringify({ numEtud, duree: parseInt(duree), - ecole: ecole || null, - pays: pays || null, - status, + 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"); @@ -815,6 +847,24 @@ function MobAddForm(

Nouvelle mobilité

+ {availableStages.length > 0 && ( +
+ + +
+ )}
setDuree((e.target as HTMLInputElement).value)} />
-
- - setEcole((e.target as HTMLInputElement).value)} - /> - - {ecoles.map((e) => -
-
- - setPays((e.target as HTMLInputElement).value)} - /> - - {paysList.map((p) => -
-
- - -
+ {!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é » +

+ )}
); diff --git a/routes/(apps)/stages/[slug].tsx b/routes/(apps)/stages/[...slug].tsx similarity index 100% rename from routes/(apps)/stages/[slug].tsx rename to routes/(apps)/stages/[...slug].tsx 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)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index a7fc770..b72abf4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -8,6 +8,8 @@ type Student = { }; 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 }; @@ -25,6 +27,8 @@ 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); @@ -38,10 +42,12 @@ export default function EditStudents({ numEtud }: Props) { useEffect(() => { async function load() { try { - const [sRes, pRes, mRes] = await Promise.all([ + const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([ fetch(`/students/api/students/${numEtud}`), fetch("/students/api/promotions"), - fetch("/admin/api/modules"), + 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(); @@ -51,6 +57,19 @@ export default function EditStudents({ numEtud }: Props) { 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 { @@ -207,30 +226,69 @@ export default function EditStudents({ numEtud }: Props) {
- {/* Section 2: Spécialisations */} + {/* Section 2: Notes */}
-

Spécialisations

-

- Fonctionnalité non disponible (endpoint non implémenté). -

-
- - {/* Section 3: Notes lecture seule */} -
-

Notes (lecture seule)

+

Notes

- Voir le récap complet des notes et moyennes de cet étudiant → + Récap complet des notes et moyennes - Récap notes + 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/static/styles/ui.css b/static/styles/ui.css index 9d2218e..6583f3c 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -40,6 +40,12 @@ 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, @@ -368,7 +374,9 @@ color: light-dark(var(--light-foreground), var(--dark-foreground)); font-size: 0.82rem; font-family: inherit; - min-width: 12rem; + min-width: 0; + width: 100%; + box-sizing: border-box; } .form-input:focus { @@ -799,7 +807,7 @@ .form-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0.75rem 1rem; margin-bottom: 0.75rem; } diff --git a/static/theme.js b/static/theme.js index af947af..041e9a8 100644 --- a/static/theme.js +++ b/static/theme.js @@ -1,15 +1,15 @@ (function () { - var t = localStorage.getItem("theme"); + const t = localStorage.getItem("theme"); if (t) document.documentElement.style.colorScheme = t; document.addEventListener("click", function (e) { - var btn = e.target.closest("#theme-toggle"); + const btn = e.target.closest("#theme-toggle"); if (!btn) return; - var cs = getComputedStyle(document.documentElement).colorScheme; - var isDark = cs === "dark" || + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || (!cs || cs === "light dark") && matchMedia("(prefers-color-scheme:dark)").matches; - var next = isDark ? "light" : "dark"; + const next = isDark ? "light" : "dark"; document.documentElement.style.colorScheme = next; localStorage.setItem("theme", next); btn.querySelector("span").textContent = next === "dark" @@ -18,10 +18,10 @@ }); document.addEventListener("DOMContentLoaded", function () { - var btn = document.getElementById("theme-toggle"); + const btn = document.getElementById("theme-toggle"); if (!btn) return; - var cs = getComputedStyle(document.documentElement).colorScheme; - var isDark = cs === "dark" || + 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/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts index 7b33ca0..3077062 100644 --- a/tests/e2e/modules_test.ts +++ b/tests/e2e/modules_test.ts @@ -34,7 +34,7 @@ Deno.test({ }); Deno.test({ - name: "e2e modules: GET /modules returns empty for non-employee", + name: "e2e modules: GET /modules returns all for non-employee", async fn() { await truncateAll(); await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); @@ -44,7 +44,7 @@ Deno.test({ ); assertEquals(res.status, 200); const body = await res.json(); - assertEquals(body.length, 0); + assertEquals(body.length, 1); }, sanitizeResources: false, sanitizeOps: false, -- 2.52.0 From ae4d4d3020f8fb9dfa49a000297ed29883a1a0f8 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:26:00 +0200 Subject: [PATCH 098/103] refactor: rename Module to ECUE, update routes, UI, and API messages refactor: rename Module to ECUE in API, UI, and error messages --- fresh.gen.ts | 14 +++++++---- routes/(apps)/admin/(_islands)/EditUser.tsx | 8 +++---- .../admin/(_islands)/ImportMaquette.tsx | 23 +++++++++---------- routes/(apps)/admin/(_props)/props.ts | 4 ++-- routes/(apps)/admin/api/modules.ts | 2 +- routes/(apps)/admin/api/ue-modules.ts | 4 ++-- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 2 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 4 ++-- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 4 ++-- routes/(apps)/notes/(_islands)/NotesView.tsx | 2 +- 10 files changed, 36 insertions(+), 31 deletions(-) diff --git a/fresh.gen.ts b/fresh.gen.ts index 4d3229d..d119210 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -31,13 +31,14 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol 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_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_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"; @@ -54,12 +55,13 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti 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_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"; @@ -145,7 +147,7 @@ const manifest = { "./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/[...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_, @@ -156,6 +158,8 @@ const manifest = { $_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": @@ -178,7 +182,7 @@ const manifest = { "./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/[...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_, @@ -186,6 +190,8 @@ const manifest = { "./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, diff --git a/routes/(apps)/admin/(_islands)/EditUser.tsx b/routes/(apps)/admin/(_islands)/EditUser.tsx index c9e45ca..2254461 100644 --- a/routes/(apps)/admin/(_islands)/EditUser.tsx +++ b/routes/(apps)/admin/(_islands)/EditUser.tsx @@ -106,7 +106,7 @@ export default function EditUser({ userId }: Props) { async function addEnseignement() { if (!addModule || !addPromo) { - setAddError("Module et Promo sont requis"); + setAddError("ECUE et Promo sont requis"); return; } setAdding(true); @@ -276,7 +276,7 @@ export default function EditUser({ userId }: Props) { class="col-dim" style="font-size: 0.75rem; margin: 0 0 0.75rem" > - Modules enseignes par cet utilisateur + ECUEs enseignes par cet utilisateur

{enseignements.length > 0 @@ -285,7 +285,7 @@ export default function EditUser({ userId }: Props) {
ModuleECUE Promo Coeff Actions
- Aucun module assigné + Aucun ECUE assigné
- + @@ -360,7 +360,7 @@ export default function EditUser({ userId }: Props) { setAddModule((e.target as HTMLSelectElement).value)} style="min-width: 12rem" > - + {modules.map((m) => ( - + @@ -485,7 +484,7 @@ export default function ImportMaquette() { ) @@ -550,7 +549,7 @@ export default function ImportMaquette() {

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

); diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index add375c..762a5df 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -8,7 +8,7 @@ const properties: AppProperties = { users: "Utilisateurs", roles: "Rôles", permissions: "Permissions", - modules: "Modules", + modules: "ECUEs", enseignements: "Enseignements", promotions: "Promotions", ues: "UEs", @@ -25,7 +25,7 @@ const properties: AppProperties = { "import-maquette", ], employeeOnly: true, - hint: "PolyMPR module", + hint: "PolyMPR ECUE", }; export default properties; diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 63ebfe1..4519db3 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -44,7 +44,7 @@ export const handler: Handlers = { if (existing) { return new Response( - JSON.stringify({ error: "Un module avec cet identifiant existe déjà" }), + JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }), { status: 409, headers: { "content-type": "application/json" } }, ); } diff --git a/routes/(apps)/admin/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts index 1a825a6..d2672d4 100644 --- a/routes/(apps)/admin/api/ue-modules.ts +++ b/routes/(apps)/admin/api/ue-modules.ts @@ -65,8 +65,8 @@ export const handler: Handlers = { headers: { "Content-Type": "application/json" }, }); } catch (error) { - console.error("Error creating UE-module:", error); - return new Response("Failed to create UE-module", { status: 500 }); + 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 index 7470e7f..b71396d 100644 --- a/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -6,7 +6,7 @@ import { and, eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = () => new Response( - JSON.stringify({ error: "Association UE-Module introuvable" }), + JSON.stringify({ error: "Association UE-ECUE introuvable" }), { status: 404, headers: { "content-type": "application/json" } }, ); diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 50168c8..1855520 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -581,7 +581,7 @@ export default function ImportNotes() { ))}

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

)} @@ -618,7 +618,7 @@ export default function ImportNotes() {

Format : Nom | Prenom |{" "} - CODE - Module (colonnes notes){" "} + 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 index de9ec39..5a516f0 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -324,14 +324,14 @@ export default function NoteRecap({ numEtud }: Props) { )} - {/* Module rows */} + {/* ECUE rows */} {ueMods.length === 0 ? (

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

) : ( diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx index 326d6e7..35cc897 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -225,7 +225,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
{mod ? mod.id : um.idModule} —{" "} - {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) + {mod ? mod.nom : "ECUE inconnu"} (coef {um.coeff}) {effective !== null ? `${effective}/20` : "—"} -- 2.52.0 From 77e0b966a58cba1f577d4c63ec38e71ea1af3593 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:35:58 +0200 Subject: [PATCH 099/103] style: fix formatting of ImportMaquette error handling block --- routes/(apps)/admin/(_islands)/ImportMaquette.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 42c2b58..5a03b6e 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -230,13 +230,14 @@ export default function ImportMaquette() { details.push({ type: "change", message: `ECUE ${mod.code} "${mod.name}" cree`, - }); - } else if (modRes.status !== 409) { - errCount++; - details.push({ + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ type: "error", message: `ECUE "${mod.code}" : creation echouee`, - }); continue; + }); + continue; } const linkRes = await fetch("/admin/api/ue-modules", { -- 2.52.0 From 5c804bd7fbbddd35bca25fa43c6a69595196aae6 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 18:43:46 +0200 Subject: [PATCH 100/103] chore: add .env.template and remove test workflow docs: remove CLAUDE.md chore(compose): delete compose.yml file --- .env.template | 8 + .github/workflows/test.yml | 79 --------- CLAUDE.md | 354 ------------------------------------- compose.yml | 24 --- 4 files changed, 8 insertions(+), 457 deletions(-) create mode 100644 .env.template delete mode 100644 .github/workflows/test.yml delete mode 100644 CLAUDE.md delete mode 100644 compose.yml 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/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d2a8d16..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,79 +0,0 @@ -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 - - - 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/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f3f37b8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,354 +0,0 @@ -# PolyMPR - Claude Code Context - -## 📋 Project Overview - -**PolyMPR** (Poly Management Platform for Resources) is a modular HR management -system built with **Deno + Fresh** framework. It's designed to help -organizations manage HR, student records, notes, mobility programs, and -role-based administration. - -### Stack - -- **Runtime**: Deno -- **Framework**: Fresh (edge-ready web framework) -- **Database**: PostgreSQL with Drizzle ORM -- **Frontend**: Preact with signals -- **Authentication**: JWT-based via cookies -- **Testing**: Deno test framework with HappyDOM for DOM testing - -### Current Status - -🚧 **In Progress** - API layer largely complete, UI pages not yet built. The -schema below is the **final/definitive schema** that guides all development. - ---- - -## 🏗️ Architecture - -### Module Structure - -The application uses a **modulith architecture** with the following modules: - -``` -routes/(apps)/ -├── students/ - Student management & promotions -├── notes/ - Grade management & academic records -├── mobility/ - Mobility programs & exchanges -└── admin/ - Role & permission management -``` - -### Key Directories - -- `/routes` - Fresh routes and components -- `/databases` - Database connection, schema, and migrations -- `/defaults` - Interfaces and shared types -- `/tests` - Unit, integration, and E2E tests -- `/static` - Public assets - -### Authentication Flow - -1. User authenticates via CAS (Polytech) -2. JWT token stored in `sessionToken` cookie -3. Middleware validates token on each request -4. Public routes: `/`, `/login`, `/logout`, `/about`, `/contact` -5. All other routes require authentication - ---- - -## 📊 Database Schema (Final/Definitive) - -```mermaid -erDiagram - USER { - string id PK - string nom - string prenom - int idRole FK - } - ROLE { - int id PK - string nom - } - PERMISSION { - int id PK - string nom - } - ROLE_PERMISSION { - int idRole PK,FK - int idPermission PK,FK - } - STUDENT { - int numEtud PK - string nom - string prenom - string idPromo FK - } - PROMOTION { - string idPromo PK - string annee - } - MODULE { - string id PK - string nom - } - ENSEIGNEMENT { - string idProf PK,FK - string idModule PK,FK - string idPromo PK,FK - } - UE { - int id PK - string nom - } - UE_MODULE { - string idModule PK,FK - int idUE PK,FK - string idPromo PK,FK - float coeff - } - NOTE { - int numEtud PK,FK - string idModule PK,FK - float note - } - AJUSTEMENT { - int numEtud PK,FK - int idUE PK,FK - float valeur - } - - USER }o--|| ROLE : "a" - ROLE_PERMISSION }o--|| ROLE : "accorde" - ROLE_PERMISSION }o--|| PERMISSION : "inclut" - ENSEIGNEMENT }o--|| USER : "réalisé par" - ENSEIGNEMENT }o--|| MODULE : "porte sur" - ENSEIGNEMENT }o--|| PROMOTION : "concerne" - STUDENT }o--|| PROMOTION : "appartient à" - UE_MODULE }o--|| MODULE : "associe" - UE_MODULE }o--|| UE : "appartient à" - UE_MODULE }o--|| PROMOTION : "pour" - NOTE }o--|| STUDENT : "reçoit" - NOTE }o--|| MODULE : "dans" - AJUSTEMENT }o--|| STUDENT : "concerne" - AJUSTEMENT }o--|| UE : "dans" -``` - -### Current Schema - -The Drizzle ORM schema in `/databases/schema.ts` implements all tables: `roles`, -`permissions`, `rolePermissions`, `users`, `promotions`, `students`, `modules`, -`enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. - ---- - -## 🎯 Open Issues (69 total) - -### UI Pages - -**Catalog** - -- 📋 UI - Page Catalogue d'applications (#71) - -**Components** - -- 🎨 UI (composant) - Popup Résultats d'import (#75) - -**Students** - -- 📋 UI - Admin – Liste des élèves (#79) -- 📋 UI - Admin – Gestion des promotions (#80) -- 📋 UI - Admin – Import xlsx élèves (#81) -- 📋 UI - Admin – Édition d'un élève (#82) - -**Notes** - -- 📋 UI - Page Élève – Mes Notes (#72) -- 📋 UI - Admin – Consulter les notes (#73) -- 📋 UI - Admin – Importer des notes (.xlsx) (#74) -- 📋 UI - Admin – Édition notes d'un élève (#76) -- 📋 UI - Admin – Récap notes élève / semestre (#77) -- 📋 UI - Admin – Gestion des UEs (#78) - -**Administration** - -- 📋 UI - Gestion des utilisateurs (#83) -- 📋 UI - Gestion des rôles (#84) -- 📋 UI - Permissions d'un rôle (#85) -- 📋 UI - Vue des permissions (#86) -- 📋 UI - Gestion des modules (#87) -- 📋 UI - Enseignements (Assignations) (#88) - ---- - -### API Endpoints - -Legend: ✅ implemented & tested | 📋 not yet implemented - -**Students API** - -- ✅ GET `/students` (#7) -- ✅ POST `/students` (#8) -- ✅ POST `/students/import-csv` (#9) -- ✅ GET `/students/{numEtud}` (#10) -- ✅ PUT `/students/{numEtud}` (#11) -- ✅ DELETE `/students/{numEtud}` (#12) -- ✅ GET `/promotions` (#13) -- ✅ POST `/promotions` (#14) -- ✅ GET `/promotions/{idPromo}` (#15) -- ✅ PUT `/promotions/{idPromo}` (#16) -- ✅ DELETE `/promotions/{idPromo}` (#17) - -**Administration API - Modules & Enseignements** - -- ✅ GET `/modules` (#23) -- ✅ POST `/modules` (#24) -- ✅ GET `/modules/{idModule}` (#25) -- ✅ PUT `/modules/{idModule}` (#26) -- ✅ DELETE `/modules/{idModule}` (#27) -- ✅ POST `/enseignements` (#29) -- ✅ GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) -- ✅ DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) - -**Notes API - UEs & UE-Modules** - -- ✅ GET `/ues` (#32) -- ✅ POST `/ues` (#33) -- ✅ GET `/ues/{idUE}` (#34) -- ✅ PUT `/ues/{idUE}` (#35) -- ✅ DELETE `/ues/{idUE}` (#36) -- ✅ GET `/ue-modules` (#37) -- ✅ POST `/ue-modules` (#38) -- ✅ GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) -- ✅ PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) -- ✅ DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) - -**Notes API - Notes & Ajustements** - -- ✅ GET `/notes` (#42) -- ✅ POST `/notes` (#43) -- 📋 POST `/notes/import-xlsx` (#44) -- ✅ GET `/notes/{numEtud}/{idModule}` (#45) -- ✅ PUT `/notes/{numEtud}/{idModule}` (#46) -- ✅ DELETE `/notes/{numEtud}/{idModule}` (#47) -- ✅ GET `/ajustements` (#48) -- ✅ POST `/ajustements` (#49) -- ✅ GET `/ajustements/{numEtud}/{idUE}` (#50) -- ✅ PUT `/ajustements/{numEtud}/{idUE}` (#51) -- ✅ DELETE `/ajustements/{numEtud}/{idUE}` (#52) - -**Administration API - Users, Roles & Permissions** - -- ✅ GET `/users` (#60) -- ✅ POST `/users` (#61) -- ✅ GET `/users/{id}` (#62) -- ✅ PUT `/users/{id}` (#63) -- ✅ DELETE `/users/{id}` (#64) -- ✅ GET `/roles` (#65) -- ✅ POST `/roles` (#66) -- ✅ GET `/roles/{idRole}` (#67) -- ✅ PUT `/roles/{idRole}` (#68) -- ✅ DELETE `/roles/{idRole}` (#69) -- ✅ GET `/permissions` (#70) - ---- - -## 🎨 Design Reference - -**Figma Prototype**: -https://www.figma.com/design/La79bsUsWnJCtMsrrt2zGd/Prototype?node-id=0-1 - -This is the **final design specification** for the UI. All UI implementations -should follow this design. - ---- - -## 🚀 Development Guidelines - -### Getting Started - -```bash -# Run tests -deno task test - -# Start development server -deno task start - -# Build for production -deno task build - -# Format & lint -deno task check -``` - -### Git Workflow - -1. Create branch: `git checkout -b PMPR-{ISSUE_ID}` -2. Implement changes -3. Run tests and linting -4. Submit PR - -### Code Style - -- Format: Follow Deno defaults (enforced via `deno fmt`) -- Linting: Fresh recommended rules -- TypeScript strict mode enabled -- Use Drizzle ORM for database operations - -### Testing - -3-level architecture — all 149 tests pass: - -- **Unit** (`tests/unit/`) — pure logic with mock DB + mock API, no real DB -- **Integration** (`tests/integration/`) — Drizzle ORM direct on real DB -- **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) - -Helpers in `tests/helpers/`: - -- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, - `makeJsonRequest`…) -- `db_integration.ts` — seed functions + `truncateAll()` for test isolation -- `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests - -```bash -deno task test # run all tests -deno task test:coverage # coverage report (terminal) -deno task test:coverage:html # coverage report (HTML → coverage/html/index.html) -nix run nixpkgs#act -- -j unit --no-cache-server # unit tests via GitHub Actions -nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via GitHub Actions -``` - ---- - -## 📦 Key Dependencies - -- **fresh@1.7.3** - Web framework -- **drizzle-orm@0.45.2** - ORM -- **pg@8.20.0** - PostgreSQL driver -- **@popov/jwt@1.0.1** - JWT utilities -- **preact@10.22.0** - UI library -- **happy-dom@16.0.0** - DOM testing - ---- - -## 🔗 Related Resources - -- **Repository**: https://git.polytech.djalim.fr/djalim/PolyMPR -- **Issue Tracker**: Gitea (via `tea` CLI) -- **Wiki**: Check CONTRIBUTING.md for dev setup -- **Database**: PostgreSQL (configured in `.env`) - ---- - -## 💡 Important Notes - -1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints - are implemented. -2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. -3. **Module Pattern**: Each module should follow the same pattern: routes, API - endpoints, components, and tests. -4. **Permissions**: All admin operations should respect the ROLE_PERMISSION - system. -5. **Fresh Conventions**: Routes use Fresh's file-based routing convention - (e.g., `routes/path/index.tsx`). -6. **Drizzle `.where()` pitfall**: Always wrap multiple conditions with `and()`. - `.where(eq(a), eq(b))` silently ignores the second argument. diff --git a/compose.yml b/compose.yml deleted file mode 100644 index f2abf83..0000000 --- a/compose.yml +++ /dev/null @@ -1,24 +0,0 @@ -services: - app: - image: registry.docker.polytech.djalim.fr/polympr:latest - ports: - - "8008:80" - - "4430:443" - volumes: - - /home/kevin/PolyMPR/:/app - command: deno run -A main.ts - deploy: - replicas: 1 - placement: - constraints: [node.role == manager] - - db: - image: postgres - restart: always - shm_size: 128mb - environment: - POSTGRES_PASSWORD: ${POSTGRES_PASS} - deploy: - replicas: 1 - placement: - constraints: [node.role == manager] -- 2.52.0 From c0aeb33193d8c0d4f23b15189f69d1e0ac55fa2c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 18:46:08 +0200 Subject: [PATCH 101/103] chore(Footer): update copyright year to 2026 --- routes/(_components)/Footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

); -- 2.52.0 From f409d9e5e865315d26ef37669d5de6a5ab5a90bc Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 19:07:37 +0200 Subject: [PATCH 102/103] refactor: add employeeOnly flag to mobility props and drop debug log --- routes/(apps)/mobility/(_props)/props.ts | 1 + routes/login.tsx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/routes/(apps)/mobility/(_props)/props.ts b/routes/(apps)/mobility/(_props)/props.ts index 3efac01..722acde 100644 --- a/routes/(apps)/mobility/(_props)/props.ts +++ b/routes/(apps)/mobility/(_props)/props.ts @@ -11,6 +11,7 @@ const properties: AppProperties = { }, 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/login.tsx b/routes/login.tsx index dd35867..3b1da1e 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -45,8 +45,6 @@ function createUserJWT(casResponse: CasResponse): Promise { } }); - console.log(fullUserInfos); - const now = Math.floor(Date.now() / 1000); const payload: LoginJWT = { iss: "PolyMPR", -- 2.52.0 From ed2fe69f54596eeb6de1b1bb202a62a146606f50 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 19:09:31 +0200 Subject: [PATCH 103/103] refactor(props): comment out my-mobility page until student page is fixed --- routes/(apps)/mobility/(_props)/props.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/(apps)/mobility/(_props)/props.ts b/routes/(apps)/mobility/(_props)/props.ts index 722acde..37e9c17 100644 --- a/routes/(apps)/mobility/(_props)/props.ts +++ b/routes/(apps)/mobility/(_props)/props.ts @@ -7,7 +7,7 @@ const properties: AppProperties = { pages: { index: "Accueil", overview: "Suivi des mobilités", - "my-mobility": "Ma mobilité", + // "my-mobility": "Ma mobilité", // TODO Fix ma mobilité page, so it renders correctly for students }, adminOnly: ["overview"], studentOnly: ["my-mobility"], -- 2.52.0
ModuleECUE Promo Actions
UEModuleECUE Code Coeff
{ue.name} - Aucun module + Aucun ECUE