diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 584803ff..00000000 --- a/package-lock.json +++ /dev/null @@ -1,2796 +0,0 @@ -{ - "name": "banking-app-fullstack-frontend", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "banking-app-fullstack-frontend", - "version": "0.0.0", - "dependencies": { - "@tailwindcss/vite": "^4.3.3", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^7.18.2", - "tailwindcss": "^4.3.3" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.1.0", - "eslint": "^10.9.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.4", - "globals": "^17.11.0", - "vite": "^8.2.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.18", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.5", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.19", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.8", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.415", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", - "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.9.1", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.5", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "17.11.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.18", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.53", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.26", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.8" - } - }, - "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", - "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.147.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.3.1", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/vite/node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore b/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore new file mode 100644 index 00000000..17cd2fc3 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/README.md b/workshops/fullstack-aws/projects/submission/Group3/backend/README.md new file mode 100644 index 00000000..489af4e4 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/README.md @@ -0,0 +1,34 @@ +# NoticeBoardTracker API + +FastAPI backend, layered: `api` (controllers) -> `services` (business logic) -> `repositories` (DB access) -> `models` (ORM). `schemas` holds the Pydantic request/response DTOs. + +## Setup + +1. `python -m venv .venv` then activate it (`.venv\Scripts\activate` on Windows, `source .venv/bin/activate` elsewhere) +2. `pip install -r requirements.txt` +3. Copy `.env.example` to `.env`, fill in `DATABASE_URL` (from whoever set up Postgres) and a `JWT_SECRET_KEY` +4. `alembic upgrade head` to create the schema +5. `uvicorn app.main:app --reload` + +Interactive API docs: `http://localhost:8000/docs` + +## Layout + +- `app/models` — SQLAlchemy ORM classes, the DB shape +- `app/schemas` — Pydantic DTOs, the API shape. All inherit `CamelModel` (`app/schemas/base.py`), so Python stays snake_case internally and JSON on the wire is camelCase automatically +- `app/repositories` — DB queries only, no business logic +- `app/services` — business logic; `TaskAssignmentFactory` is where a task fans out into one `task_assignments` row per trainee +- `app/api/v1` — routers, thin HTTP layer only +- `app/dependencies/auth.py` — JWT decode + role guards (`get_current_manager`, `get_current_trainee`) +- `alembic/versions` — migrations, the source of truth for DB structure; don't hand-edit the shared DB, add a migration instead + +## Adding a migration + +After changing a model in `app/models/`: + +``` +alembic revision --autogenerate -m "describe the change" +alembic upgrade head +``` + +Review the generated file before committing — autogenerate doesn't always get constraints/enums exactly right. diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini new file mode 100644 index 00000000..a97945fe --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py new file mode 100644 index 00000000..7366706e --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.core.database import Base +from app.models import * # noqa: F401,F403 - register all models on Base.metadata + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# configparser treats "%" as interpolation syntax (URL-encoded passwords hit +# this), so escape it before handing the URL to alembic's Config. +config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%")) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako new file mode 100644 index 00000000..3217cf0f --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 00000000..02fe1b96 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0001_initial_schema.py @@ -0,0 +1,118 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-08-28 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + +user_role = sa.Enum("trainee", "manager", "hr", name="user_role") +urgency_level = sa.Enum("low", "medium", "high", "urgent", name="urgency_level") + + +def upgrade() -> None: + op.create_table( + "cohorts", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("manager_id", sa.Integer, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("email", sa.String(255), unique=True, nullable=False), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("full_name", sa.String(255), nullable=False), + sa.Column("role", user_role, nullable=False), + sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_foreign_key("fk_cohorts_manager_id", "cohorts", "users", ["manager_id"], ["id"]) + + op.create_table( + "tasks", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("cohort_id", sa.Integer, sa.ForeignKey("cohorts.id"), nullable=True), + sa.Column("due_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("urgency", urgency_level, nullable=False, server_default="medium"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "subtasks", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("order_index", sa.Integer, nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "task_assignments", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False), + sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("current_percentage", sa.SmallInteger, nullable=False, server_default="0"), + sa.Column("last_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("task_id", "trainee_id", name="uq_task_assignment"), + sa.CheckConstraint("current_percentage BETWEEN 0 AND 100", name="ck_task_assignment_pct"), + ) + + op.create_table( + "subtask_completions", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("subtask_id", sa.Integer, sa.ForeignKey("subtasks.id", ondelete="CASCADE"), nullable=False), + sa.Column("trainee_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("is_completed", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("subtask_id", "trainee_id", name="uq_subtask_completion"), + ) + + op.create_table( + "progress_updates", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "task_assignment_id", + sa.Integer, + sa.ForeignKey("task_assignments.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("percentage", sa.SmallInteger, nullable=False), + sa.Column("comment", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.CheckConstraint("percentage BETWEEN 0 AND 100", name="ck_progress_update_pct"), + ) + + op.create_index("idx_task_assignments_task", "task_assignments", ["task_id"]) + op.create_index("idx_task_assignments_trainee", "task_assignments", ["trainee_id"]) + op.create_index("idx_progress_updates_assignment", "progress_updates", ["task_assignment_id", "created_at"]) + + +def downgrade() -> None: + op.drop_table("progress_updates") + op.drop_table("subtask_completions") + op.drop_table("task_assignments") + op.drop_table("subtasks") + op.drop_table("tasks") + op.drop_constraint("fk_cohorts_manager_id", "cohorts", type_="foreignkey") + op.drop_table("users") + op.drop_table("cohorts") + urgency_level.drop(op.get_bind(), checkfirst=True) + user_role.drop(op.get_bind(), checkfirst=True) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py new file mode 100644 index 00000000..51129137 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/alembic/versions/0002_add_manager_id_to_users.py @@ -0,0 +1,59 @@ +"""add manager_id to users + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-08-29 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add manager_id to users. + # + # This is nullable because manager users do not belong + # to another manager. Trainee users will have their + # manager's user.id stored here. + op.add_column( + "users", + sa.Column( + "manager_id", + sa.Integer(), + nullable=True, + ), + ) + + # Self-referencing foreign key: + # + # users.manager_id -> users.id + op.create_foreign_key( + "fk_users_manager_id", + "users", + "users", + ["manager_id"], + ["id"], + ) + + +def downgrade() -> None: + # Remove the self-referencing foreign key first. + op.drop_constraint( + "fk_users_manager_id", + "users", + type_="foreignkey", + ) + + # Then remove the column. + op.drop_column( + "users", + "manager_id", + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py new file mode 100644 index 00000000..f8349f36 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/auth.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.models.user import User +from app.schemas.auth import LoginRequest, ProfileUpdate, TokenResponse, UserOut +from app.services.auth_service import AuthService + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse: + service = AuthService(db) + user = service.authenticate(payload.email, payload.password) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password") + token = service.issue_token(user) + return TokenResponse(access_token=token, role=user.role.value, user_id=user.id) + + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)) -> User: + return current_user + + +@router.put("/me", response_model=UserOut) +def update_me( + payload: ProfileUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> User: + try: + return AuthService(db).update_profile(current_user, payload) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py new file mode 100644 index 00000000..8baeb8d8 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_cohorts.py @@ -0,0 +1,158 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_manager +from app.models.user import User +from app.schemas.cohort import ( + CohortCreate, + CohortDetailOut, + CohortOut, + CohortTaskOut, + CohortTraineeOut, +) +from app.services.cohort_service import CohortService + + +router = APIRouter( + prefix="/manager/cohorts", + tags=["manager-cohorts"], +) + + +# ============================================================ +# CREATE COHORT +# ============================================================ + +@router.post( + "", + response_model=CohortOut, + status_code=status.HTTP_201_CREATED, +) +def create_cohort( + payload: CohortCreate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> CohortOut: + + cohort = CohortService(db).create_cohort( + manager.id, + payload, + ) + + return CohortOut( + id=cohort.id, + name=cohort.name, + description=cohort.description, + trainee_count=0, + ) + + +# ============================================================ +# LIST COHORTS +# ============================================================ + +@router.get( + "", + response_model=list[CohortOut], +) +def list_cohorts( + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> list[CohortOut]: + + cohorts = CohortService(db).list_manager_cohorts( + manager.id, + ) + + return [ + CohortOut( + id=cohort.id, + name=cohort.name, + description=cohort.description, + trainee_count=len(cohort.trainees), + ) + for cohort in cohorts + ] + + +# ============================================================ +# GET COHORT +# ============================================================ + +@router.get( + "/{cohort_id}", + response_model=CohortDetailOut, +) +def get_cohort( + cohort_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> CohortDetailOut: + + service = CohortService(db) + + cohort = service.get_manager_cohort( + manager.id, + cohort_id, + ) + + if cohort is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cohort not found", + ) + + trainees = [ + CohortTraineeOut( + id=trainee.id, + full_name=trainee.full_name, + email=trainee.email, + current_percentage=0, + ) + for trainee in cohort.trainees + ] + + return CohortDetailOut( + id=cohort.id, + name=cohort.name, + description=cohort.description, + trainees=trainees, + tasks=[ + CohortTaskOut( + id=task.id, + title=task.title, + description=task.description, + due_date=task.due_date, + urgency=task.urgency, + ) + for task in cohort.tasks + ], + ) + +# ============================================================ +# DELETE COHORT +# ============================================================ + +@router.delete( + "/{cohort_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_cohort( + cohort_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> None: + + deleted = CohortService(db).delete_cohort( + manager.id, + cohort_id, + ) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cohort not found", + ) + + return None diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py new file mode 100644 index 00000000..21ef9f86 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_progress.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_manager +from app.models.user import User +from app.schemas.task import TaskProgressOut, TraineeProgressDetailOut +from app.services.task_service import TaskService + +router = APIRouter(prefix="/manager/tasks", tags=["manager-progress"]) + + +@router.get("/{task_id}/progress", response_model=TaskProgressOut) +def task_progress( + task_id: int, db: Session = Depends(get_db), manager: User = Depends(get_current_manager) +) -> dict: + return TaskService(db).get_task_progress_summary(manager.id, task_id) + + +@router.get("/{task_id}/progress/{trainee_id}", response_model=TraineeProgressDetailOut) +def trainee_progress( + task_id: int, + trainee_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> dict: + return TaskService(db).get_trainee_progress_detail(manager.id, task_id, trainee_id) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py new file mode 100644 index 00000000..59f1b741 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_tasks.py @@ -0,0 +1,321 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_manager +from app.models.user import User +from app.schemas.task import ( + SubtaskCreate, + SubtaskOut, + SubtaskUpdate, + TaskCreate, + TaskDetailOut, + TaskOut, + TaskUpdate, +) +from app.services.task_service import TaskService + + +router = APIRouter( + prefix="/manager/tasks", + tags=["manager-tasks"], +) + +subtask_router = APIRouter( + prefix="/manager/subtasks", + tags=["manager-tasks"], +) + + +# ============================================================ +# TASK -> RESPONSE +# ============================================================ + +def _to_task_out(task) -> TaskOut: + return TaskOut( + id=task.id, + title=task.title, + description=task.description, + due_date=task.due_date, + urgency=task.urgency, + + # Cohort information + cohort_id=task.cohort_id, + cohort=( + { + "id": task.cohort.id, + "name": task.cohort.name, + } + if task.cohort + else None + ), + + assigned_trainee_count=len(task.assignments), + ) + + +# ============================================================ +# CREATE TASK +# ============================================================ + +@router.post( + "", + response_model=TaskOut, + status_code=status.HTTP_201_CREATED, +) +def create_task( + payload: TaskCreate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TaskOut: + + task = TaskService(db).create_task( + manager.id, + payload, + ) + + return _to_task_out(task) + + +# ============================================================ +# LIST MANAGER TASKS +# ============================================================ + +@router.get( + "", + response_model=list[TaskOut], +) +def list_tasks( + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> list[TaskOut]: + + tasks = TaskService(db).list_manager_tasks( + manager.id, + ) + + return [ + _to_task_out(task) + for task in tasks + ] + + +# ============================================================ +# GET SINGLE TASK +# ============================================================ + +@router.get( + "/{task_id}", + response_model=TaskDetailOut, +) +def get_task( + task_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TaskDetailOut: + + service = TaskService(db) + + task = service.get_owned_task( + manager.id, + task_id, + ) + + subtasks = service.subtasks.list_by_task( + task_id, + ) + + return TaskDetailOut( + id=task.id, + title=task.title, + description=task.description, + due_date=task.due_date, + urgency=task.urgency, + + # Cohort information + cohort_id=task.cohort_id, + cohort=( + { + "id": task.cohort.id, + "name": task.cohort.name, + } + if task.cohort + else None + ), + + subtasks=[ + SubtaskOut( + id=subtask.id, + title=subtask.title, + order_index=subtask.order_index, + ) + for subtask in subtasks + ], + ) + + +# ============================================================ +# UPDATE TASK +# ============================================================ + +@router.put( + "/{task_id}", + response_model=TaskOut, +) +def update_task( + task_id: int, + payload: TaskUpdate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TaskOut: + + task = TaskService(db).update_task( + manager.id, + task_id, + payload, + ) + + return _to_task_out(task) + + +# ============================================================ +# DELETE TASK +# ============================================================ + +@router.delete( + "/{task_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_task( + task_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> None: + + TaskService(db).delete_task( + manager.id, + task_id, + ) + + +# ============================================================ +# ADD SUBTASK +# ============================================================ + +@router.post( + "/{task_id}/subtasks", + response_model=SubtaskOut, + status_code=status.HTTP_201_CREATED, +) +def add_subtask( + task_id: int, + payload: SubtaskCreate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> SubtaskOut: + + subtask = TaskService(db).add_subtask( + manager.id, + task_id, + payload.title, + payload.order_index, + ) + + return SubtaskOut( + id=subtask.id, + title=subtask.title, + order_index=subtask.order_index, + ) + + +# ============================================================ +# UPDATE SUBTASK +# ============================================================ + +@subtask_router.put( + "/{subtask_id}", + response_model=SubtaskOut, +) +def update_subtask( + subtask_id: int, + payload: SubtaskUpdate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> SubtaskOut: + + service = TaskService(db) + + subtask = service.subtasks.get_by_id( + subtask_id, + ) + + if subtask is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subtask not found", + ) + + # Make sure the manager owns the task this + # subtask belongs to. + service.get_owned_task( + manager.id, + subtask.task_id, + ) + + for field, value in payload.model_dump( + exclude_unset=True, + ).items(): + setattr( + subtask, + field, + value, + ) + + db.commit() + db.refresh(subtask) + + return SubtaskOut( + id=subtask.id, + title=subtask.title, + order_index=subtask.order_index, + ) + + +# ============================================================ +# DELETE SUBTASK +# ============================================================ + +@subtask_router.delete( + "/{subtask_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_subtask( + subtask_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> None: + + service = TaskService(db) + + subtask = service.subtasks.get_by_id( + subtask_id, + ) + + if subtask is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subtask not found", + ) + + # Make sure the manager owns the task this + # subtask belongs to. + service.get_owned_task( + manager.id, + subtask.task_id, + ) + + service.subtasks.delete( + subtask, + ) + + db.commit() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py new file mode 100644 index 00000000..4c769e3b --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/manager_trainees.py @@ -0,0 +1,206 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_manager +from app.models.enums import UserRole +from app.models.user import User +from app.schemas.trainee import ( + TraineeCreate, + TraineeListItem, + TraineeOut, + TraineeUpdate, + TraineeTaskOut, + TraineeCohortOut, +) +from app.services.trainee_service import TraineeService + + +router = APIRouter( + prefix="/manager/trainees", + tags=["manager-trainees"], +) + + +# ============================================================ +# HELPERS +# ============================================================ + +def _to_trainee_list_item(trainee: User) -> TraineeListItem: + return TraineeListItem( + id=trainee.id, + full_name=trainee.full_name, + email=trainee.email, + cohort=( + TraineeCohortOut( + id=trainee.cohort.id, + name=trainee.cohort.name, + ) + if trainee.cohort + else None + ), + ) + + +def _to_trainee_out(trainee: User) -> TraineeOut: + tasks = [] + + for assignment in trainee.task_assignments: + task = assignment.task + + tasks.append( + TraineeTaskOut( + id=task.id, + title=task.title, + description=task.description, + due_date=task.due_date, + urgency=task.urgency, + current_percentage=assignment.current_percentage, + ) + ) + + return TraineeOut( + id=trainee.id, + full_name=trainee.full_name, + email=trainee.email, + cohort=( + TraineeCohortOut( + id=trainee.cohort.id, + name=trainee.cohort.name, + ) + if trainee.cohort + else None + ), + tasks=tasks, + ) + + +# ============================================================ +# LIST TRAINEES +# ============================================================ + +@router.get( + "", + response_model=list[TraineeListItem], +) +def list_trainees( + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> list[TraineeListItem]: + + trainees = TraineeService(db).list_manager_trainees( + manager.id, + ) + + return [ + _to_trainee_list_item(trainee) + for trainee in trainees + ] + + +# ============================================================ +# GET TRAINEE +# ============================================================ + +@router.get( + "/{trainee_id}", + response_model=TraineeOut, +) +def get_trainee( + trainee_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TraineeOut: + + trainee = TraineeService(db).get_manager_trainee( + manager.id, + trainee_id, + ) + + if trainee is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trainee not found", + ) + + return _to_trainee_out(trainee) + + +# ============================================================ +# CREATE TRAINEE +# ============================================================ + +@router.post( + "", + response_model=TraineeOut, + status_code=status.HTTP_201_CREATED, +) +def create_trainee( + payload: TraineeCreate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TraineeOut: + + trainee = TraineeService(db).create_trainee( + manager.id, + payload, + ) + + return _to_trainee_out(trainee) + + +# ============================================================ +# UPDATE TRAINEE +# ============================================================ + +@router.put( + "/{trainee_id}", + response_model=TraineeOut, +) +def update_trainee( + trainee_id: int, + payload: TraineeUpdate, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> TraineeOut: + + trainee = TraineeService(db).update_trainee( + manager.id, + trainee_id, + payload, + ) + + if trainee is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trainee not found", + ) + + return _to_trainee_out(trainee) + +# ============================================================ +# DELETE TRAINEE +# ============================================================ + +@router.delete( + "/{trainee_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_trainee( + trainee_id: int, + db: Session = Depends(get_db), + manager: User = Depends(get_current_manager), +) -> None: + + deleted = TraineeService(db).delete_trainee( + manager.id, + trainee_id, + ) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trainee not found", + ) + + return None diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py new file mode 100644 index 00000000..984430ea --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/router.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + auth, + manager_cohorts, + manager_progress, + manager_tasks, + manager_trainees, + student_tasks, +) + +api_router = APIRouter(prefix="/api") + +api_router.include_router(auth.router) +api_router.include_router(manager_cohorts.router) +api_router.include_router(manager_trainees.router) +api_router.include_router(manager_tasks.router) +api_router.include_router(manager_tasks.subtask_router) +api_router.include_router(manager_progress.router) +api_router.include_router(student_tasks.router) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py new file mode 100644 index 00000000..658fe8f7 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/api/v1/student_tasks.py @@ -0,0 +1,110 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_trainee +from app.models.user import User +from app.schemas.progress import ( + ProgressUpdateCreate, + ProgressUpdateOut, + StudentTaskDetail, + StudentTaskListItem, + SubtaskToggle, + SubtaskToggleOut, +) +from app.services.progress_service import ProgressService + + +# ============================================================ +# STUDENT / TRAINEE ENDPOINTS +# +# Base prefix: /student +# +# GET /student/tasks +# Get all tasks assigned to the current trainee. +# +# GET /student/tasks/{task_id} +# Get details for a specific task. +# +# PATCH /student/subtasks/{subtask_id} +# Toggle a subtask's completion status. +# +# POST /student/tasks/{task_id}/progress +# Submit a progress update for a task. +# +# GET /student/tasks/{task_id}/progress/history +# Get the progress history for a task. +# +# NOTE: +# The application may add an additional prefix such as +# /api/v1 when this router is included in main.py. +# +# Example: +# /api/v1/student/tasks +# ============================================================ + +router = APIRouter(prefix="/student", tags=["student"]) + + +@router.get("/tasks", response_model=list[StudentTaskListItem]) +def list_tasks( + sort: str | None = Query(default=None), + db: Session = Depends(get_db), + trainee: User = Depends(get_current_trainee), +) -> list[dict]: + return ProgressService(db).list_student_tasks(trainee.id, sort) + + +@router.get("/tasks/{task_id}", response_model=StudentTaskDetail) +def task_detail( + task_id: int, + db: Session = Depends(get_db), + trainee: User = Depends(get_current_trainee), +) -> dict: + return ProgressService(db).get_student_task_detail(trainee.id, task_id) + + +@router.patch("/subtasks/{subtask_id}", response_model=SubtaskToggleOut) +def toggle_subtask( + subtask_id: int, + payload: SubtaskToggle, + db: Session = Depends(get_db), + trainee: User = Depends(get_current_trainee), +) -> dict: + return ProgressService(db).toggle_subtask( + trainee.id, + subtask_id, + payload.is_completed, + ) + + +@router.post( + "/tasks/{task_id}/progress", + response_model=ProgressUpdateOut, + status_code=status.HTTP_201_CREATED, +) +def submit_progress( + task_id: int, + payload: ProgressUpdateCreate, + db: Session = Depends(get_db), + trainee: User = Depends(get_current_trainee), +) -> dict: + return ProgressService(db).submit_progress_update( + trainee.id, + task_id, + payload.percentage, + payload.comment, + ) + + +@router.get( + "/tasks/{task_id}/progress/history", + response_model=list[ProgressUpdateOut], +) +def progress_history( + task_id: int, + db: Session = Depends(get_db), + trainee: User = Depends(get_current_trainee), +) -> list[ProgressUpdateOut]: + history = ProgressService(db).get_progress_history(trainee.id, task_id) + return [ProgressUpdateOut(task_id=task_id, **entry) for entry in history] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py new file mode 100644 index 00000000..16ef9eb6 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/config.py @@ -0,0 +1,18 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + database_url: str + jwt_secret_key: str + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 60 + cors_origins: list[str] = ["http://localhost:5173"] + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py new file mode 100644 index 00000000..33ab17bd --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() + +# One engine, one session factory for the whole process - the Singleton +# the rest of the app depends on for DB access. +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py new file mode 100644 index 00000000..43d1f03f --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/core/security.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(subject: int, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": str(subject), "role": role, "exp": expire} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict: + try: + return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + except JWTError as exc: + raise ValueError("invalid token") from exc diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py new file mode 100644 index 00000000..0392dd2c --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/dependencies/auth.py @@ -0,0 +1,38 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models.enums import UserRole +from app.models.user import User +from app.repositories.user_repository import UserRepository + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + credentials_error = HTTPException(status.HTTP_401_UNAUTHORIZED, "Could not validate credentials") + try: + payload = decode_access_token(token) + user_id = int(payload["sub"]) + except (ValueError, KeyError): + raise credentials_error + + user = UserRepository(db).get_by_id(user_id) + if user is None: + raise credentials_error + return user + + +def require_role(*roles: UserRole): + def dependency(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Not permitted for this role") + return user + + return dependency + + +get_current_manager = require_role(UserRole.manager) +get_current_trainee = require_role(UserRole.trainee) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py new file mode 100644 index 00000000..686c4252 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/main.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import api_router +from app.core.config import get_settings + +settings = get_settings() + +app = FastAPI(title="NoticeBoardTracker API") + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py new file mode 100644 index 00000000..184a50d6 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/__init__.py @@ -0,0 +1,17 @@ +from app.models.cohort import Cohort +from app.models.progress_update import ProgressUpdate +from app.models.subtask import Subtask +from app.models.subtask_completion import SubtaskCompletion +from app.models.task import Task +from app.models.task_assignment import TaskAssignment +from app.models.user import User + +__all__ = [ + "User", + "Cohort", + "Task", + "Subtask", + "TaskAssignment", + "SubtaskCompletion", + "ProgressUpdate", +] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py new file mode 100644 index 00000000..e6324fe7 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/cohort.py @@ -0,0 +1,69 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Cohort(Base): + __tablename__ = "cohorts" + + id: Mapped[int] = mapped_column( + primary_key=True, + ) + + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + description: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + # ======================================================== + # MANAGER + # ======================================================== + + manager_id: Mapped[int | None] = mapped_column( + ForeignKey( + "users.id", + ondelete="SET NULL", + ), + nullable=True, + ) + + # ======================================================== + # TIMESTAMPS + # ======================================================== + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + + # ======================================================== + # TRAINEES + # ======================================================== + + # Deleting a cohort does NOT delete its trainees. + # Their users.cohort_id is set to NULL. + trainees = relationship( + "User", + foreign_keys="User.cohort_id", + back_populates="cohort", + ) + + # ======================================================== + # TASKS + # ======================================================== + + # Deleting a cohort does NOT delete its tasks. + # Their tasks.cohort_id should be set to NULL. + tasks = relationship( + "Task", + foreign_keys="Task.cohort_id", + back_populates="cohort", + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py new file mode 100644 index 00000000..f219a65a --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/enums.py @@ -0,0 +1,14 @@ +import enum + + +class UserRole(str, enum.Enum): + trainee = "trainee" + manager = "manager" + hr = "hr" + + +class UrgencyLevel(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + urgent = "urgent" diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py new file mode 100644 index 00000000..e0856587 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/progress_update.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, SmallInteger, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class ProgressUpdate(Base): + """Append-only history log - a new row per trainee update, never an overwrite.""" + + __tablename__ = "progress_updates" + __table_args__ = (CheckConstraint("percentage BETWEEN 0 AND 100", name="ck_progress_update_pct"),) + + id: Mapped[int] = mapped_column(primary_key=True) + task_assignment_id: Mapped[int] = mapped_column( + ForeignKey("task_assignments.id", ondelete="CASCADE"), nullable=False + ) + percentage: Mapped[int] = mapped_column(SmallInteger, nullable=False) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + assignment = relationship("TaskAssignment", back_populates="history") diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py new file mode 100644 index 00000000..b734dc12 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Subtask(Base): + __tablename__ = "subtasks" + + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + task = relationship("Task", back_populates="subtasks") diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py new file mode 100644 index 00000000..b4735bf3 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/subtask_completion.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class SubtaskCompletion(Base): + __tablename__ = "subtask_completions" + __table_args__ = (UniqueConstraint("subtask_id", "trainee_id", name="uq_subtask_completion"),) + + id: Mapped[int] = mapped_column(primary_key=True) + subtask_id: Mapped[int] = mapped_column(ForeignKey("subtasks.id", ondelete="CASCADE"), nullable=False) + trainee_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + subtask = relationship("Subtask") + trainee = relationship("User") diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py new file mode 100644 index 00000000..d6ad4c99 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task.py @@ -0,0 +1,72 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.enums import UrgencyLevel + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(primary_key=True) + + title: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + description: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + created_by: Mapped[int] = mapped_column( + ForeignKey("users.id"), + nullable=False, + ) + + cohort_id: Mapped[int | None] = mapped_column( + ForeignKey("cohorts.id"), + nullable=True, + ) + + due_date: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + urgency: Mapped[UrgencyLevel] = mapped_column( + Enum(UrgencyLevel, name="urgency_level"), + default=UrgencyLevel.medium, + nullable=False, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + cohort = relationship( + "Cohort", + back_populates="tasks", + ) + + subtasks = relationship( + "Subtask", + back_populates="task", + cascade="all, delete-orphan", + ) + + assignments = relationship( + "TaskAssignment", + back_populates="task", + cascade="all, delete-orphan", + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py new file mode 100644 index 00000000..a6530b10 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/task_assignment.py @@ -0,0 +1,80 @@ +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, SmallInteger, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class TaskAssignment(Base): + """Per-trainee anchor for a task - a cohort task fans out into one row per trainee here.""" + + __tablename__ = "task_assignments" + + __table_args__ = ( + UniqueConstraint( + "task_id", + "trainee_id", + name="uq_task_assignment", + ), + CheckConstraint( + "current_percentage BETWEEN 0 AND 100", + name="ck_task_assignment_pct", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + + task_id: Mapped[int] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), + nullable=False, + ) + + trainee_id: Mapped[int] = mapped_column( + ForeignKey("users.id"), + nullable=False, + ) + + current_percentage: Mapped[int] = mapped_column( + SmallInteger, + default=0, + nullable=False, + ) + + last_updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + + # ======================================================== + # TASK + # ======================================================== + + task = relationship( + "Task", + back_populates="assignments", + ) + + # ======================================================== + # TRAINEE + # ======================================================== + + trainee = relationship( + "User", + back_populates="task_assignments", + ) + + # ======================================================== + # PROGRESS HISTORY + # ======================================================== + + history = relationship( + "ProgressUpdate", + back_populates="assignment", + cascade="all, delete-orphan", + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py new file mode 100644 index 00000000..bcf465da --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/models/user.py @@ -0,0 +1,96 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.enums import UserRole + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + + email: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + ) + + hashed_password: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + full_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, name="user_role"), + nullable=False, + ) + + # ======================================================== + # MANAGER + # ======================================================== + + manager_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), + nullable=True, + ) + + manager = relationship( + "User", + remote_side=[id], + foreign_keys=[manager_id], + back_populates="managed_trainees", + ) + + managed_trainees = relationship( + "User", + foreign_keys=[manager_id], + back_populates="manager", + ) + + # ======================================================== + # COHORT + # ======================================================== + + cohort_id: Mapped[int | None] = mapped_column( + ForeignKey("cohorts.id"), + nullable=True, + ) + + cohort = relationship( + "Cohort", + foreign_keys=[cohort_id], + back_populates="trainees", + ) + + # ======================================================== + # TIMESTAMPS + # ======================================================== + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + # ======================================================== + # TASK ASSIGNMENTS + # ======================================================== + + task_assignments = relationship( + "TaskAssignment", + back_populates="trainee", + cascade="all, delete-orphan", + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py new file mode 100644 index 00000000..9315e932 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/progress_repository.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models.progress_update import ProgressUpdate +from app.models.subtask_completion import SubtaskCompletion + + +class ProgressRepository: + def __init__(self, db: Session): + self.db = db + + def add_history_entry(self, task_assignment_id: int, percentage: int, comment: str | None) -> ProgressUpdate: + entry = ProgressUpdate(task_assignment_id=task_assignment_id, percentage=percentage, comment=comment) + self.db.add(entry) + self.db.flush() + return entry + + def list_history(self, task_assignment_id: int) -> list[ProgressUpdate]: + return ( + self.db.query(ProgressUpdate) + .filter(ProgressUpdate.task_assignment_id == task_assignment_id) + .order_by(ProgressUpdate.created_at.desc()) + .all() + ) + + def get_completion(self, subtask_id: int, trainee_id: int) -> SubtaskCompletion | None: + return ( + self.db.query(SubtaskCompletion) + .filter(SubtaskCompletion.subtask_id == subtask_id, SubtaskCompletion.trainee_id == trainee_id) + .first() + ) + + def set_completion(self, subtask_id: int, trainee_id: int, is_completed: bool) -> SubtaskCompletion: + completion = self.get_completion(subtask_id, trainee_id) + if completion is None: + completion = SubtaskCompletion(subtask_id=subtask_id, trainee_id=trainee_id) + self.db.add(completion) + completion.is_completed = is_completed + completion.completed_at = datetime.now(timezone.utc) if is_completed else None + self.db.flush() + return completion + + def list_completions_by_task(self, subtask_ids: list[int], trainee_id: int) -> dict[int, bool]: + if not subtask_ids: + return {} + rows = ( + self.db.query(SubtaskCompletion) + .filter(SubtaskCompletion.subtask_id.in_(subtask_ids), SubtaskCompletion.trainee_id == trainee_id) + .all() + ) + return {row.subtask_id: row.is_completed for row in rows} diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py new file mode 100644 index 00000000..6ff78445 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/subtask_repository.py @@ -0,0 +1,27 @@ +from sqlalchemy.orm import Session + +from app.models.subtask import Subtask + + +class SubtaskRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, subtask: Subtask) -> Subtask: + self.db.add(subtask) + self.db.flush() + return subtask + + def get_by_id(self, subtask_id: int) -> Subtask | None: + return self.db.get(Subtask, subtask_id) + + def list_by_task(self, task_id: int) -> list[Subtask]: + return ( + self.db.query(Subtask) + .filter(Subtask.task_id == task_id) + .order_by(Subtask.order_index) + .all() + ) + + def delete(self, subtask: Subtask) -> None: + self.db.delete(subtask) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py new file mode 100644 index 00000000..3a3e5a97 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_assignment_repository.py @@ -0,0 +1,26 @@ +from sqlalchemy.orm import Session + +from app.models.task_assignment import TaskAssignment + + +class TaskAssignmentRepository: + def __init__(self, db: Session): + self.db = db + + def bulk_create(self, assignments: list[TaskAssignment]) -> list[TaskAssignment]: + self.db.add_all(assignments) + self.db.flush() + return assignments + + def list_by_task(self, task_id: int) -> list[TaskAssignment]: + return self.db.query(TaskAssignment).filter(TaskAssignment.task_id == task_id).all() + + def get_by_task_and_trainee(self, task_id: int, trainee_id: int) -> TaskAssignment | None: + return ( + self.db.query(TaskAssignment) + .filter(TaskAssignment.task_id == task_id, TaskAssignment.trainee_id == trainee_id) + .first() + ) + + def list_by_trainee(self, trainee_id: int) -> list[TaskAssignment]: + return self.db.query(TaskAssignment).filter(TaskAssignment.trainee_id == trainee_id).all() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py new file mode 100644 index 00000000..3016f3d0 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/task_repository.py @@ -0,0 +1,27 @@ +from sqlalchemy.orm import Session + +from app.models.task import Task + + +class TaskRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, task: Task) -> Task: + self.db.add(task) + self.db.flush() + return task + + def get_by_id(self, task_id: int) -> Task | None: + return self.db.get(Task, task_id) + + def list_by_manager(self, manager_id: int) -> list[Task]: + return ( + self.db.query(Task) + .filter(Task.created_by == manager_id) + .order_by(Task.created_at.desc()) + .all() + ) + + def delete(self, task: Task) -> None: + self.db.delete(task) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py new file mode 100644 index 00000000..70c77e70 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/repositories/user_repository.py @@ -0,0 +1,17 @@ +from sqlalchemy.orm import Session + +from app.models.user import User + + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get_by_id(self, user_id: int) -> User | None: + return self.db.get(User, user_id) + + def get_by_email(self, email: str) -> User | None: + return self.db.query(User).filter(User.email == email).first() + + def list_by_cohort(self, cohort_id: int) -> list[User]: + return self.db.query(User).filter(User.cohort_id == cohort_id).all() diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py new file mode 100644 index 00000000..50926555 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/auth.py @@ -0,0 +1,30 @@ +from pydantic import EmailStr, Field + +from app.schemas.base import CamelModel + + +class LoginRequest(CamelModel): + email: EmailStr + password: str + + +class ProfileUpdate(CamelModel): + full_name: str | None = None + email: EmailStr | None = None + current_password: str | None = None + new_password: str | None = Field(default=None, min_length=8) + + +class TokenResponse(CamelModel): + access_token: str + token_type: str = "bearer" + role: str + user_id: int + + +class UserOut(CamelModel): + id: int + email: EmailStr + full_name: str + role: str + cohort_id: int | None = None diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py new file mode 100644 index 00000000..0eda4fab --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/base.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class CamelModel(BaseModel): + """Base for every schema: Python stays snake_case, JSON on the wire is camelCase.""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + from_attributes=True, + ) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py new file mode 100644 index 00000000..b04d2e08 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/cohort.py @@ -0,0 +1,123 @@ +from datetime import datetime + +from app.schemas.base import CamelModel + +# ============================================================ +# CREATE COHORT +# ============================================================ + +class CohortCreate(CamelModel): + name: str + description: str | None = None + + +# ============================================================ +# COHORT TRAINEE +# ============================================================ + +class CohortTraineeOut(CamelModel): + id: int + full_name: str + email: str + current_percentage: int = 0 + + +# ============================================================ +# COHORT TASK +# ============================================================ + +class CohortTaskOut(CamelModel): + id: int + title: str + description: str | None + due_date: datetime | None + urgency: str + + # Average progress for this task across assigned trainees. + average_percentage: float = 0.0 + + +# ============================================================ +# COHORT LIST +# ============================================================ + +class CohortOut(CamelModel): + id: int + name: str + description: str | None + trainee_count: int + + +# ============================================================ +# PROGRESS HISTORY +# ============================================================ + +class ProgressHistoryEntry(CamelModel): + percentage: int + comment: str | None = None + created_at: datetime + + +# ============================================================ +# SUBTASK PROGRESS +# ============================================================ + +class CohortSubtaskOut(CamelModel): + id: int + title: str + order_index: int + + # True when the trainee has completed this subtask. + is_completed: bool = False + + # Derived percentage: + # incomplete = 0 + # complete = 100 + percentage: int = 0 + + +# ============================================================ +# TRAINEE TASK PROGRESS +# ============================================================ + +class CohortTraineeTaskOut(CamelModel): + id: int + title: str + description: str | None = None + + # Overall progress for this task for this trainee. + current_percentage: int = 0 + + subtasks: list[CohortSubtaskOut] = [] + + history: list[ProgressHistoryEntry] = [] + + +# ============================================================ +# COHORT TRAINEE DETAIL +# ============================================================ + +class CohortTraineeDetailOut(CamelModel): + id: int + full_name: str + email: str + + # Overall progress for the trainee's cohort tasks. + current_percentage: int = 0 + + # Tasks assigned to this trainee through the cohort. + tasks: list[CohortTraineeTaskOut] = [] + + +# ============================================================ +# COHORT DETAIL +# ============================================================ + +class CohortDetailOut(CamelModel): + id: int + name: str + description: str | None + + trainees: list[CohortTraineeDetailOut] = [] + + tasks: list[CohortTaskOut] = [] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py new file mode 100644 index 00000000..6b83cd70 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/progress.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pydantic import Field + +from app.models.enums import UrgencyLevel +from app.schemas.base import CamelModel +from app.schemas.task import SubtaskOut + + +class SubtaskToggle(CamelModel): + is_completed: bool + + +class SubtaskToggleOut(CamelModel): + id: int + is_completed: bool + + +class ProgressUpdateCreate(CamelModel): + percentage: int = Field(ge=0, le=100) + comment: str | None = None + + +class ProgressUpdateOut(CamelModel): + task_id: int + percentage: int + comment: str | None + created_at: datetime + + +class StudentTaskListItem(CamelModel): + task_id: int + title: str + due_date: datetime | None + urgency: UrgencyLevel + current_percentage: int + subtask_summary: str + + +class StudentTaskDetail(CamelModel): + id: int + title: str + description: str | None + due_date: datetime | None + urgency: UrgencyLevel + subtasks: list[SubtaskOut] + current_percentage: int diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py new file mode 100644 index 00000000..62ee1d8d --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/task.py @@ -0,0 +1,229 @@ +from datetime import datetime + +from pydantic import Field, model_validator + +from app.models.enums import UrgencyLevel +from app.schemas.base import CamelModel + + +# ============================================================ +# SUBTASKS +# ============================================================ + + +class SubtaskCreate(CamelModel): + title: str + order_index: int = 0 + + +class SubtaskUpdate(CamelModel): + title: str | None = None + order_index: int | None = None + + +class SubtaskOut(CamelModel): + id: int + title: str + order_index: int + is_completed: bool = False + + +# ============================================================ +# TASK CREATE / UPDATE +# ============================================================ + + +class TaskCreate(CamelModel): + title: str + description: str | None = None + due_date: datetime | None = None + urgency: UrgencyLevel = UrgencyLevel.medium + + # A task can be assigned either to: + # 1. an entire cohort + # 2. individual trainees + # + # Exactly one target must be provided. + + cohort_id: int | None = None + trainee_ids: list[int] | None = None + + subtasks: list[SubtaskCreate] = Field(default_factory=list) + + @model_validator(mode="after") + def check_exactly_one_target(self) -> "TaskCreate": + has_cohort = self.cohort_id is not None + has_trainees = bool(self.trainee_ids) + + if has_cohort == has_trainees: + raise ValueError( + "provide exactly one of cohortId or traineeIds" + ) + + return self + + +class TaskUpdate(CamelModel): + title: str | None = None + description: str | None = None + due_date: datetime | None = None + urgency: UrgencyLevel | None = None + + +# ============================================================ +# COHORT REFERENCE +# ============================================================ + + +class CohortRef(CamelModel): + id: int + name: str + + +# ============================================================ +# TRAINEE REFERENCE +# ============================================================ + + +class TraineeRef(CamelModel): + id: int + full_name: str + + +# ============================================================ +# TASK OUTPUT +# ============================================================ + + +class TaskOut(CamelModel): + id: int + title: str + description: str | None + due_date: datetime | None + urgency: UrgencyLevel + + # Cohort information. + # + # Example JSON: + # + # { + # "cohortId": 1, + # "cohort": { + # "id": 1, + # "name": "Cohort A" + # } + # } + + cohort_id: int | None = None + cohort: CohortRef | None = None + + assigned_trainee_count: int + + +class TaskDetailOut(CamelModel): + id: int + title: str + description: str | None + due_date: datetime | None + urgency: UrgencyLevel + + cohort_id: int | None = None + cohort: CohortRef | None = None + + subtasks: list[SubtaskOut] + + +# ============================================================ +# TASK PROGRESS +# ============================================================ + + +class TraineeProgressSummary(CamelModel): + """ + Summary shown when viewing task progress. + + current_percentage should be calculated by the backend + from the trainee's completed subtasks. + + For the completed-subtask calculation: + + completed_subtasks / total_subtasks * 100 + + Example: + + 3 completed / 4 total = 75% + """ + + trainee_id: int + full_name: str + current_percentage: int + last_updated_at: datetime | None = None + + +class TaskProgressOut(CamelModel): + """ + Overall progress for a task. + + average_percentage is the average of the individual + trainee percentages for this task. + """ + + task_id: int + average_percentage: float + trainees: list[TraineeProgressSummary] + + +# ============================================================ +# PROGRESS HISTORY +# ============================================================ + + +class ProgressHistoryEntry(CamelModel): + """ + A historical progress update made for a trainee. + + The frontend can display the percentage, comment, + and timestamp when the trainee's progress is expanded. + """ + + percentage: int + comment: str | None = None + created_at: datetime + + +# ============================================================ +# TRAINEE PROGRESS DETAIL +# ============================================================ + + +class TraineeProgressDetailOut(CamelModel): + """ + Detailed progress for one trainee on one task. + + Used by: + + GET /manager/tasks/{task_id}/progress/{trainee_id} + + This allows the frontend to display: + + Trainee + Current Progress + Subtask 1 + Subtask 2 + Subtask 3 + Progress History + 50% - comment + 75% - comment + 100% - comment + """ + + trainee: TraineeRef + + # Calculated from completed subtasks. + current_percentage: int + + # All subtasks for the task, including their completion state. + subtasks: list[SubtaskOut] + + # Historical progress updates/comments. + history: list[ProgressHistoryEntry] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py new file mode 100644 index 00000000..e2c585a5 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/schemas/trainee.py @@ -0,0 +1,72 @@ +from datetime import datetime + +from pydantic import Field + +from app.models.enums import UrgencyLevel +from app.schemas.base import CamelModel + + +# ============================================================ +# COHORT +# ============================================================ + +class TraineeCohortOut(CamelModel): + id: int + name: str + + +# ============================================================ +# TASK +# ============================================================ + +class TraineeTaskOut(CamelModel): + id: int + title: str + description: str | None + due_date: datetime | None + urgency: UrgencyLevel + current_percentage: int + + +# ============================================================ +# TRAINEE DETAIL +# ============================================================ + +class TraineeOut(CamelModel): + id: int + full_name: str + email: str + cohort: TraineeCohortOut | None + tasks: list[TraineeTaskOut] + + +# ============================================================ +# TRAINEE LIST ITEM +# ============================================================ + +class TraineeListItem(CamelModel): + id: int + full_name: str + email: str + cohort: TraineeCohortOut | None + + +# ============================================================ +# CREATE TRAINEE +# ============================================================ + +class TraineeCreate(CamelModel): + full_name: str = Field(min_length=1) + email: str + password: str = Field(min_length=8) + cohort_id: int | None = None + + +# ============================================================ +# UPDATE TRAINEE +# ============================================================ + +class TraineeUpdate(CamelModel): + full_name: str | None = Field(default=None, min_length=1) + email: str | None = None + cohort_id: int | None = None \ No newline at end of file diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py new file mode 100644 index 00000000..193db97b --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/auth_service.py @@ -0,0 +1,42 @@ +from sqlalchemy.orm import Session + +from app.core.security import create_access_token, hash_password, verify_password +from app.models.user import User +from app.repositories.user_repository import UserRepository +from app.schemas.auth import ProfileUpdate + + +class AuthService: + def __init__(self, db: Session): + self.db = db + self.users = UserRepository(db) + + def authenticate(self, email: str, password: str) -> User | None: + user = self.users.get_by_email(email) + if user is None or not verify_password(password, user.hashed_password): + return None + return user + + def issue_token(self, user: User) -> str: + return create_access_token(subject=user.id, role=user.role.value) + + def update_profile(self, user: User, payload: ProfileUpdate) -> User: + if payload.email and payload.email != user.email: + existing = self.users.get_by_email(payload.email) + if existing is not None and existing.id != user.id: + raise ValueError("Email is already in use") + user.email = payload.email + + if payload.full_name: + user.full_name = payload.full_name + + if payload.new_password: + if not payload.current_password or not verify_password( + payload.current_password, user.hashed_password + ): + raise ValueError("Current password is incorrect") + user.hashed_password = hash_password(payload.new_password) + + self.db.commit() + self.db.refresh(user) + return user diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py new file mode 100644 index 00000000..95b8a830 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/cohort_service.py @@ -0,0 +1,236 @@ +from sqlalchemy.orm import Session, joinedload + +from app.models.task import Task +from app.models.cohort import Cohort +from app.models.user import User + +from app.repositories.progress_repository import ProgressRepository +from app.repositories.task_assignment_repository import TaskAssignmentRepository + +from app.schemas.cohort import CohortCreate + + +class CohortService: + def __init__(self, db: Session): + self.db = db + self.progress = ProgressRepository(db) + self.assignments = TaskAssignmentRepository(db) + + # ============================================================ + # CREATE + # ============================================================ + + def create_cohort( + self, + manager_id: int, + payload: CohortCreate, + ) -> Cohort: + + cohort = Cohort( + name=payload.name.strip(), + description=( + payload.description.strip() + if payload.description + else None + ), + manager_id=manager_id, + ) + + self.db.add(cohort) + self.db.commit() + self.db.refresh(cohort) + + return cohort + + # ============================================================ + # LIST + # ============================================================ + + def list_manager_cohorts( + self, + manager_id: int, + ) -> list[Cohort]: + + return ( + self.db.query(Cohort) + .options( + joinedload(Cohort.trainees), + ) + .filter( + Cohort.manager_id == manager_id, + ) + .all() + ) + + # ============================================================ + # GET + # ============================================================ + + def get_manager_cohort( + self, + manager_id: int, + cohort_id: int, + ) -> Cohort | None: + + return ( + self.db.query(Cohort) + .options( + joinedload(Cohort.trainees), + joinedload(Cohort.tasks), + ) + .filter( + Cohort.id == cohort_id, + Cohort.manager_id == manager_id, + ) + .first() + ) + + # ============================================================ + # TRAINEE PROGRESS + # ============================================================ + + def get_trainee_progress( + self, + trainee_id: int, + task_id: int, + ) -> int: + + assignment = ( + self.assignments.get_by_task_and_trainee( + task_id, + trainee_id, + ) + ) + + if assignment is None: + return 0 + + return int( + assignment.current_percentage or 0 + ) + + # ============================================================ + # COHORT TASK AVERAGE + # ============================================================ + + def get_task_average_percentage( + self, + task_id: int, + ) -> float: + + assignments = self.assignments.list_by_task( + task_id, + ) + + if not assignments: + return 0.0 + + total = sum( + int(assignment.current_percentage or 0) + for assignment in assignments + ) + + return round( + total / len(assignments), + 1, + ) + + # ============================================================ + # COHORT TRAINEE AVERAGE + # ============================================================ + + def get_cohort_trainee_percentage( + self, + trainee_id: int, + tasks, + ) -> int: + + if not tasks: + return 0 + + percentages = [] + + for task in tasks: + assignment = ( + self.assignments.get_by_task_and_trainee( + task.id, + trainee_id, + ) + ) + + if assignment is not None: + percentages.append( + int( + assignment.current_percentage or 0 + ) + ) + + if not percentages: + return 0 + + return round( + sum(percentages) / len(percentages) + ) + + # ============================================================ + # DELETE + # ============================================================ + + def delete_cohort( + self, + manager_id: int, + cohort_id: int, + ) -> bool: + + cohort = ( + self.db.query(Cohort) + .filter( + Cohort.id == cohort_id, + Cohort.manager_id == manager_id, + ) + .first() + ) + + if cohort is None: + return False + + # -------------------------------------------------------- + # Remove trainees from the cohort. + # + # Trainees are NOT deleted. + # Their cohort_id is simply cleared. + # -------------------------------------------------------- + + self.db.query(User).filter( + User.cohort_id == cohort.id, + ).update( + { + User.cohort_id: None, + }, + synchronize_session=False, + ) + + # -------------------------------------------------------- + # Remove tasks from the cohort. + # + # Tasks are NOT deleted. + # Their cohort_id is simply cleared. + # -------------------------------------------------------- + + self.db.query(Task).filter( + Task.cohort_id == cohort.id, + ).update( + { + Task.cohort_id: None, + }, + synchronize_session=False, + ) + + # -------------------------------------------------------- + # Delete the cohort + # -------------------------------------------------------- + + self.db.delete(cohort) + self.db.commit() + + return True diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py new file mode 100644 index 00000000..6d2a98a3 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/progress_service.py @@ -0,0 +1,316 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.task_assignment import TaskAssignment +from app.repositories.progress_repository import ProgressRepository +from app.repositories.subtask_repository import SubtaskRepository +from app.repositories.task_assignment_repository import TaskAssignmentRepository + + +_URGENCY_RANK = { + "urgent": 0, + "high": 1, + "medium": 2, + "low": 3, +} + + +class ProgressService: + + def __init__(self, db: Session): + self.db = db + self.subtasks = SubtaskRepository(db) + self.assignments = TaskAssignmentRepository(db) + self.progress = ProgressRepository(db) + + # ======================================================== + # HELPERS + # ======================================================== + + def _get_own_assignment( + self, + trainee_id: int, + task_id: int, + ) -> TaskAssignment: + + assignment = self.assignments.get_by_task_and_trainee( + task_id, + trainee_id, + ) + + if assignment is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "Task not assigned to this trainee", + ) + + return assignment + + # ======================================================== + # STUDENT TASK LIST + # ======================================================== + + def list_student_tasks( + self, + trainee_id: int, + sort: str | None = None, + ) -> list[dict]: + + assignments = self.assignments.list_by_trainee(trainee_id) + + items = [] + + for assignment in assignments: + + task = assignment.task + + subtasks = self.subtasks.list_by_task(task.id) + + completions = self.progress.list_completions_by_task( + [subtask.id for subtask in subtasks], + trainee_id, + ) + + completed_count = sum( + 1 + for subtask in subtasks + if completions.get(subtask.id, False) + ) + + items.append( + { + "task_id": task.id, + "title": task.title, + "due_date": task.due_date, + "urgency": task.urgency, + "current_percentage": assignment.current_percentage, + "subtask_summary": ( + f"{completed_count}/{len(subtasks)} completed" + ), + } + ) + + if sort == "urgency": + items.sort( + key=lambda item: _URGENCY_RANK.get( + item["urgency"].value, + 4, + ) + ) + + return items + + # ======================================================== + # STUDENT TASK DETAIL + # ======================================================== + + def get_student_task_detail( + self, + trainee_id: int, + task_id: int, + ) -> dict: + + assignment = self._get_own_assignment( + trainee_id, + task_id, + ) + + task = assignment.task + + subtasks = self.subtasks.list_by_task(task_id) + + completions = self.progress.list_completions_by_task( + [subtask.id for subtask in subtasks], + trainee_id, + ) + + return { + "id": task.id, + "title": task.title, + "description": task.description, + "due_date": task.due_date, + "urgency": task.urgency, + + "subtasks": [ + { + "id": subtask.id, + "title": subtask.title, + "order_index": subtask.order_index, + "is_completed": completions.get( + subtask.id, + False, + ), + } + for subtask in subtasks + ], + + "current_percentage": assignment.current_percentage, + } + + # ======================================================== + # TOGGLE SUBTASK + # ======================================================== + + def toggle_subtask( + self, + trainee_id: int, + subtask_id: int, + is_completed: bool, + ) -> dict: + + # ---------------------------------------------------- + # Find subtask + # ---------------------------------------------------- + + subtask = self.subtasks.get_by_id(subtask_id) + + if subtask is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "Subtask not found", + ) + + # ---------------------------------------------------- + # Make sure trainee owns the task + # ---------------------------------------------------- + + assignment = self._get_own_assignment( + trainee_id, + subtask.task_id, + ) + + # ---------------------------------------------------- + # Update completion + # ---------------------------------------------------- + + completion = self.progress.set_completion( + subtask_id, + trainee_id, + is_completed, + ) + + # ---------------------------------------------------- + # Get all subtasks for this task + # ---------------------------------------------------- + + subtasks = self.subtasks.list_by_task( + subtask.task_id + ) + + # ---------------------------------------------------- + # Get completion status + # ---------------------------------------------------- + + completions = self.progress.list_completions_by_task( + [s.id for s in subtasks], + trainee_id, + ) + + # ---------------------------------------------------- + # Calculate progress + # ---------------------------------------------------- + + completed_count = sum( + 1 + for s in subtasks + if completions.get(s.id, False) + ) + + total_count = len(subtasks) + + if total_count > 0: + percentage = round( + (completed_count / total_count) * 100 + ) + else: + percentage = 0 + + # ---------------------------------------------------- + # Update task assignment + # ---------------------------------------------------- + + assignment.current_percentage = percentage + + assignment.last_updated_at = datetime.now( + timezone.utc + ) + + self.db.commit() + + # ---------------------------------------------------- + # Return result + # ---------------------------------------------------- + + return { + "id": subtask_id, + "is_completed": completion.is_completed, + "percentage": percentage, + } + + # ======================================================== + # MANUAL PROGRESS UPDATE + # ======================================================== + + def submit_progress_update( + self, + trainee_id: int, + task_id: int, + percentage: int, + comment: str | None, + ) -> dict: + + assignment = self._get_own_assignment( + trainee_id, + task_id, + ) + + entry = self.progress.add_history_entry( + assignment.id, + percentage, + comment, + ) + + assignment.current_percentage = percentage + + assignment.last_updated_at = datetime.now( + timezone.utc + ) + + self.db.commit() + + return { + "task_id": task_id, + "percentage": entry.percentage, + "comment": entry.comment, + "created_at": entry.created_at, + } + + # ======================================================== + # PROGRESS HISTORY + # ======================================================== + + def get_progress_history( + self, + trainee_id: int, + task_id: int, + ) -> list[dict]: + + assignment = self._get_own_assignment( + trainee_id, + task_id, + ) + + history = self.progress.list_history( + assignment.id + ) + + return [ + { + "percentage": entry.percentage, + "comment": entry.comment, + "created_at": entry.created_at, + } + for entry in history + ] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py new file mode 100644 index 00000000..fa3b9c96 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_assignment_factory.py @@ -0,0 +1,14 @@ +from app.models.task_assignment import TaskAssignment +from app.models.user import User + + +class TaskAssignmentFactory: + """Builds the per-trainee task_assignments fan-out for a newly created task.""" + + @staticmethod + def build_for_cohort(task_id: int, trainees: list[User]) -> list[TaskAssignment]: + return [TaskAssignment(task_id=task_id, trainee_id=trainee.id) for trainee in trainees] + + @staticmethod + def build_for_trainee_ids(task_id: int, trainee_ids: list[int]) -> list[TaskAssignment]: + return [TaskAssignment(task_id=task_id, trainee_id=trainee_id) for trainee_id in trainee_ids] diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py new file mode 100644 index 00000000..ccb75b69 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/task_service.py @@ -0,0 +1,121 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.subtask import Subtask +from app.models.task import Task +from app.repositories.progress_repository import ProgressRepository +from app.repositories.subtask_repository import SubtaskRepository +from app.repositories.task_assignment_repository import TaskAssignmentRepository +from app.repositories.task_repository import TaskRepository +from app.repositories.user_repository import UserRepository +from app.schemas.task import TaskCreate, TaskUpdate +from app.services.task_assignment_factory import TaskAssignmentFactory + + +class TaskService: + def __init__(self, db: Session): + self.db = db + self.tasks = TaskRepository(db) + self.subtasks = SubtaskRepository(db) + self.assignments = TaskAssignmentRepository(db) + self.users = UserRepository(db) + self.progress = ProgressRepository(db) + + def create_task(self, manager_id: int, payload: TaskCreate) -> Task: + task = Task( + title=payload.title, + description=payload.description, + created_by=manager_id, + cohort_id=payload.cohort_id, + due_date=payload.due_date, + urgency=payload.urgency, + ) + self.tasks.create(task) + + for item in payload.subtasks: + self.subtasks.create(Subtask(task_id=task.id, title=item.title, order_index=item.order_index)) + + if payload.cohort_id: + trainees = self.users.list_by_cohort(payload.cohort_id) + assignments = TaskAssignmentFactory.build_for_cohort(task.id, trainees) + else: + assignments = TaskAssignmentFactory.build_for_trainee_ids(task.id, payload.trainee_ids or []) + + self.assignments.bulk_create(assignments) + self.db.commit() + self.db.refresh(task) + return task + + def get_owned_task(self, manager_id: int, task_id: int) -> Task: + task = self.tasks.get_by_id(task_id) + if task is None or task.created_by != manager_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found") + return task + + def list_manager_tasks(self, manager_id: int) -> list[Task]: + return self.tasks.list_by_manager(manager_id) + + def update_task(self, manager_id: int, task_id: int, payload: TaskUpdate) -> Task: + task = self.get_owned_task(manager_id, task_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(task, field, value) + self.db.commit() + self.db.refresh(task) + return task + + def delete_task(self, manager_id: int, task_id: int) -> None: + task = self.get_owned_task(manager_id, task_id) + self.tasks.delete(task) + self.db.commit() + + def add_subtask(self, manager_id: int, task_id: int, title: str, order_index: int) -> Subtask: + self.get_owned_task(manager_id, task_id) + subtask = self.subtasks.create(Subtask(task_id=task_id, title=title, order_index=order_index)) + self.db.commit() + self.db.refresh(subtask) + return subtask + + def get_task_progress_summary(self, manager_id: int, task_id: int) -> dict: + self.get_owned_task(manager_id, task_id) + rows = self.assignments.list_by_task(task_id) + average = sum(row.current_percentage for row in rows) / len(rows) if rows else 0.0 + return { + "task_id": task_id, + "average_percentage": round(average, 1), + "trainees": [ + { + "trainee_id": row.trainee_id, + "full_name": row.trainee.full_name, + "current_percentage": row.current_percentage, + "last_updated_at": row.last_updated_at, + } + for row in rows + ], + } + + def get_trainee_progress_detail(self, manager_id: int, task_id: int, trainee_id: int) -> dict: + self.get_owned_task(manager_id, task_id) + assignment = self.assignments.get_by_task_and_trainee(task_id, trainee_id) + if assignment is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Trainee is not assigned to this task") + + subtasks = self.subtasks.list_by_task(task_id) + completions = self.progress.list_completions_by_task([s.id for s in subtasks], trainee_id) + history = self.progress.list_history(assignment.id) + + return { + "trainee": {"id": assignment.trainee.id, "full_name": assignment.trainee.full_name}, + "current_percentage": assignment.current_percentage, + "subtasks": [ + { + "id": s.id, + "title": s.title, + "order_index": s.order_index, + "is_completed": completions.get(s.id, False), + } + for s in subtasks + ], + "history": [ + {"percentage": h.percentage, "comment": h.comment, "created_at": h.created_at} for h in history + ], + } diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py new file mode 100644 index 00000000..87e94be5 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/app/services/trainee_service.py @@ -0,0 +1,450 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session, joinedload + +from app.core.security import hash_password +from app.models.cohort import Cohort +from app.models.enums import UserRole +from app.models.task import Task +from app.models.user import User +from app.models.task_assignment import TaskAssignment +from app.models.subtask_completion import SubtaskCompletion +from app.schemas.trainee import TraineeCreate, TraineeUpdate + + +class TraineeService: + + def __init__(self, db: Session): + self.db = db + + # ======================================================== + # LIST + # ======================================================== + + def list_manager_trainees( + self, + manager_id: int, + ) -> list[User]: + + return ( + self.db.query(User) + .filter( + User.manager_id == manager_id, + User.role == UserRole.trainee, + ) + .options( + joinedload(User.cohort), + ) + .order_by(User.full_name) + .all() + ) + + # ======================================================== + # GET + # ======================================================== + + def get_manager_trainee( + self, + manager_id: int, + trainee_id: int, + ) -> User | None: + + return ( + self.db.query(User) + .filter( + User.id == trainee_id, + User.manager_id == manager_id, + User.role == UserRole.trainee, + ) + .options( + joinedload(User.cohort), + joinedload(User.task_assignments).joinedload( + TaskAssignment.task + ), + ) + .first() + ) + + # ======================================================== + # CREATE + # ======================================================== + + def create_trainee( + self, + manager_id: int, + payload: TraineeCreate, + ) -> User: + + # ---------------------------------------------------- + # Normalize email + # ---------------------------------------------------- + + email = payload.email.strip().lower() + + # ---------------------------------------------------- + # Check for existing email + # ---------------------------------------------------- + + existing_user = ( + self.db.query(User) + .filter( + User.email == email, + ) + .first() + ) + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists.", + ) + + # ---------------------------------------------------- + # Validate cohort + # + # A trainee may be created without a cohort. + # + # If a cohort is supplied, it must belong to the + # current manager. + # ---------------------------------------------------- + + cohort_id = payload.cohort_id + + if cohort_id is not None: + + cohort = ( + self.db.query(Cohort) + .filter( + Cohort.id == cohort_id, + Cohort.manager_id == manager_id, + ) + .first() + ) + + if cohort is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cohort not found.", + ) + + # ---------------------------------------------------- + # Create trainee + # ---------------------------------------------------- + + trainee = User( + full_name=payload.full_name.strip(), + email=email, + hashed_password=hash_password(payload.password), + role=UserRole.trainee, + + # Manager owns the trainee directly. + manager_id=manager_id, + + # Can be NULL. + cohort_id=cohort_id, + ) + + self.db.add(trainee) + + # We need trainee.id before creating assignments. + self.db.flush() + + # ---------------------------------------------------- + # ASSIGN COHORT TASKS + # + # If a cohort was selected, create one + # TaskAssignment for every task belonging to it. + # ---------------------------------------------------- + + if cohort_id is not None: + + cohort_tasks = ( + self.db.query(Task) + .filter( + Task.cohort_id == cohort_id, + ) + .all() + ) + + for task in cohort_tasks: + + assignment = TaskAssignment( + task_id=task.id, + trainee_id=trainee.id, + current_percentage=0, + ) + + self.db.add(assignment) + + # ---------------------------------------------------- + # Commit everything together + # ---------------------------------------------------- + + self.db.commit() + + self.db.refresh(trainee) + + return trainee + + # ======================================================== + # UPDATE + # ======================================================== + + def update_trainee( + self, + manager_id: int, + trainee_id: int, + payload: TraineeUpdate, + ) -> User | None: + + # ---------------------------------------------------- + # Find trainee belonging to this manager + # + # manager_id is the source of truth for ownership. + # ---------------------------------------------------- + + trainee = ( + self.db.query(User) + .filter( + User.id == trainee_id, + User.manager_id == manager_id, + User.role == UserRole.trainee, + ) + .first() + ) + + if trainee is None: + return None + + # ---------------------------------------------------- + # Get supplied fields + # ---------------------------------------------------- + + update_data = payload.model_dump( + exclude_unset=True, + ) + + # ---------------------------------------------------- + # Normalize email + # ---------------------------------------------------- + + if "email" in update_data: + + if update_data["email"] is not None: + + email = update_data["email"].strip().lower() + + existing_user = ( + self.db.query(User) + .filter( + User.email == email, + User.id != trainee_id, + ) + .first() + ) + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists.", + ) + + update_data["email"] = email + + # ---------------------------------------------------- + # Normalize full name + # ---------------------------------------------------- + + if "full_name" in update_data: + + if update_data["full_name"] is not None: + + update_data["full_name"] = ( + update_data["full_name"].strip() + ) + + # ---------------------------------------------------- + # COHORT CHANGE + # + # None is allowed. + # + # Examples: + # + # None -> Cohort A + # Cohort A -> Cohort B + # Cohort A -> None + # + # When assigning/changing to a cohort, all tasks from + # the new cohort are assigned to the trainee. + # + # When removing/changing cohort, existing task + # assignments are removed. + # ---------------------------------------------------- + + cohort_changed = "cohort_id" in update_data + + old_cohort_id = trainee.cohort_id + new_cohort_id = update_data.get( + "cohort_id", + old_cohort_id, + ) + + if cohort_changed: + + # ------------------------------------------------ + # Validate new cohort + # ------------------------------------------------ + + if new_cohort_id is not None: + + cohort = ( + self.db.query(Cohort) + .filter( + Cohort.id == new_cohort_id, + Cohort.manager_id == manager_id, + ) + .first() + ) + + if cohort is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cohort not found.", + ) + + # ------------------------------------------------ + # Only do task reassignment if the cohort actually + # changed. + # ------------------------------------------------ + + if old_cohort_id != new_cohort_id: + + # -------------------------------------------- + # Remove existing task assignments. + # + # This means the trainee's task list always + # matches their current cohort. + # -------------------------------------------- + + self.db.query(TaskAssignment).filter( + TaskAssignment.trainee_id == trainee.id, + ).delete( + synchronize_session=False, + ) + + # -------------------------------------------- + # Assign all tasks from the new cohort. + # + # If new_cohort_id is None, no tasks are added. + # -------------------------------------------- + + if new_cohort_id is not None: + + cohort_tasks = ( + self.db.query(Task) + .filter( + Task.cohort_id == new_cohort_id, + ) + .all() + ) + + for task in cohort_tasks: + + assignment = TaskAssignment( + task_id=task.id, + trainee_id=trainee.id, + current_percentage=0, + ) + + self.db.add(assignment) + + # ---------------------------------------------------- + # Apply normal trainee updates + # ---------------------------------------------------- + + for field, value in update_data.items(): + + setattr( + trainee, + field, + value, + ) + + # ---------------------------------------------------- + # Commit + # ---------------------------------------------------- + + self.db.commit() + + self.db.refresh(trainee) + + return trainee + + # ======================================================== + # DELETE + # ======================================================== + + def delete_trainee( + self, + manager_id: int, + trainee_id: int, + ) -> bool: + + # ---------------------------------------------------- + # Find trainee belonging to this manager + # + # Do not use cohort_id because it may be NULL. + # ---------------------------------------------------- + + trainee = ( + self.db.query(User) + .filter( + User.id == trainee_id, + User.manager_id == manager_id, + User.role == UserRole.trainee, + ) + .first() + ) + + if trainee is None: + return False + + # ---------------------------------------------------- + # Delete subtask completion records + # + # These records reference users.id through + # subtask_completions.trainee_id. + # + # They must be deleted before the trainee. + # ---------------------------------------------------- + + self.db.query(SubtaskCompletion).filter( + SubtaskCompletion.trainee_id == trainee.id, + ).delete( + synchronize_session=False, + ) + + # ---------------------------------------------------- + # Delete task assignments + # + # These records also reference the trainee. + # ---------------------------------------------------- + + self.db.query(TaskAssignment).filter( + TaskAssignment.trainee_id == trainee.id, + ).delete( + synchronize_session=False, + ) + + # ---------------------------------------------------- + # Delete trainee + # ---------------------------------------------------- + + self.db.delete(trainee) + + # ---------------------------------------------------- + # Commit everything together + # ---------------------------------------------------- + + self.db.commit() + + return True diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py b/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py new file mode 100644 index 00000000..a6a2d6fe --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/lambda_handler.py @@ -0,0 +1,5 @@ +from mangum import Mangum + +from app.main import app + +handler = Mangum(app) diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt b/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt new file mode 100644 index 00000000..e6057915 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +psycopg2-binary>=2.9 +pydantic[email]>=2.7 +pydantic-settings>=2.3 +python-jose[cryptography]>=3.3 +passlib[bcrypt]>=1.7 +bcrypt<4.0 +python-multipart>=0.0.9 +alembic>=1.13 diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/__init__.py b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py new file mode 100644 index 00000000..3588ee7e --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/backend/scripts/seed_dev_data.py @@ -0,0 +1,413 @@ +"""Seeds a clean development database. + +This script deletes all existing application data and then creates: + +- 1 manager +- 1 cohort +- 2 trainees +- 2 tasks +- 6 subtasks +- Task assignments for both trainees + +Run from the backend/ directory: + + python -m scripts.seed_dev_data + +WARNING: + This permanently deletes all data in the development database. + Do NOT use this script against a production database. +""" + +from datetime import datetime, timedelta, timezone + +from app.core.database import SessionLocal +from app.core.security import hash_password + +from app.models.cohort import Cohort +from app.models.enums import UserRole, UrgencyLevel +from app.models.user import User +from app.models.task import Task +from app.models.subtask import Subtask +from app.models.task_assignment import TaskAssignment +from app.models.subtask_completion import SubtaskCompletion +from app.models.progress_update import ProgressUpdate + + +def clear_database(db) -> None: + """Delete all application data in dependency-safe order.""" + + print("Clearing existing development data...") + + # ======================================================== + # BREAK USER <-> COHORT FOREIGN KEY CYCLE + # + # users.cohort_id -> cohorts.id + # cohorts.manager_id -> users.id + # + # We first remove those references so PostgreSQL allows + # us to delete the records. + # ======================================================== + + db.execute( + User.__table__.update().values( + manager_id=None, + cohort_id=None, + ) + ) + + db.execute( + Cohort.__table__.update().values( + manager_id=None, + ) + ) + + # ======================================================== + # CHILD TABLES + # ======================================================== + + db.query(ProgressUpdate).delete( + synchronize_session=False + ) + + db.query(SubtaskCompletion).delete( + synchronize_session=False + ) + + db.query(TaskAssignment).delete( + synchronize_session=False + ) + + db.query(Subtask).delete( + synchronize_session=False + ) + + db.query(Task).delete( + synchronize_session=False + ) + + # ======================================================== + # COHORTS + # ======================================================== + + db.query(Cohort).delete( + synchronize_session=False + ) + + # ======================================================== + # USERS + # ======================================================== + + db.query(User).delete( + synchronize_session=False + ) + + db.commit() + + print("Database cleared.") + + +def seed_database(db) -> None: + """Create development seed data.""" + + # ======================================================== + # MANAGER + # ======================================================== + + manager = User( + email="manager@noticeboardtracker.dev", + hashed_password=hash_password("Manager123!"), + full_name="Morgan Manager", + role=UserRole.manager, + ) + + db.add(manager) + db.flush() + + # ======================================================== + # COHORT + # ======================================================== + + cohort = Cohort( + name="Cohort Alpha", + description="Test cohort", + manager_id=manager.id, + ) + + db.add(cohort) + db.flush() + + # ======================================================== + # TRAINEES + # ======================================================== + + trainees = [ + User( + email=f"trainee{i}@noticeboardtracker.dev", + hashed_password=hash_password("Trainee123!"), + full_name=f"Trainee {i}", + role=UserRole.trainee, + manager_id=manager.id, + cohort_id=cohort.id, + ) + for i in range(1, 3) + ] + + db.add_all(trainees) + db.flush() + + # ======================================================== + # TASK 1 + # ======================================================== + + task1 = Task( + title="Complete Python Onboarding", + description=( + "Complete the initial Python training and demonstrate " + "understanding of the core concepts." + ), + created_by=manager.id, + cohort_id=cohort.id, + due_date=datetime.now(timezone.utc) + timedelta(days=7), + urgency=UrgencyLevel.medium, + ) + + db.add(task1) + db.flush() + + # ======================================================== + # TASK 1 SUBTASKS + # ======================================================== + + task1_subtasks = [ + Subtask( + task_id=task1.id, + title="Review Python syntax and data types", + order_index=1, + ), + Subtask( + task_id=task1.id, + title="Complete Python functions exercise", + order_index=2, + ), + Subtask( + task_id=task1.id, + title="Complete Python classes exercise", + order_index=3, + ), + ] + + db.add_all(task1_subtasks) + db.flush() + + # ======================================================== + # TASK 2 + # ======================================================== + + task2 = Task( + title="Build Noticeboard API Feature", + description=( + "Implement a small API feature using FastAPI and " + "SQLAlchemy." + ), + created_by=manager.id, + cohort_id=cohort.id, + due_date=datetime.now(timezone.utc) + timedelta(days=14), + urgency=UrgencyLevel.high, + ) + + db.add(task2) + db.flush() + + # ======================================================== + # TASK 2 SUBTASKS + # ======================================================== + + task2_subtasks = [ + Subtask( + task_id=task2.id, + title="Create the database model", + order_index=1, + ), + Subtask( + task_id=task2.id, + title="Create the repository and service", + order_index=2, + ), + Subtask( + task_id=task2.id, + title="Create and test the API endpoint", + order_index=3, + ), + ] + + db.add_all(task2_subtasks) + db.flush() + + # ======================================================== + # TASK ASSIGNMENTS + # + # Both tasks are assigned to both trainees. + # ======================================================== + + assignments = [] + + for trainee in trainees: + assignments.append( + TaskAssignment( + task_id=task1.id, + trainee_id=trainee.id, + current_percentage=0, + ) + ) + + assignments.append( + TaskAssignment( + task_id=task2.id, + trainee_id=trainee.id, + current_percentage=0, + ) + ) + + db.add_all(assignments) + db.flush() + + # ======================================================== + # OPTIONAL SAMPLE PROGRESS + # + # Give Trainee 1 some progress so the manager dashboard + # has meaningful data immediately after seeding. + # ======================================================== + + trainee1 = trainees[0] + + task1_assignment = next( + assignment + for assignment in assignments + if assignment.task_id == task1.id + and assignment.trainee_id == trainee1.id + ) + + task1_assignment.current_percentage = 33 + task1_assignment.last_updated_at = datetime.now(timezone.utc) + + # Mark the first subtask complete for Trainee 1. + + db.add( + SubtaskCompletion( + subtask_id=task1_subtasks[0].id, + trainee_id=trainee1.id, + is_completed=True, + completed_at=datetime.now(timezone.utc), + ) + ) + + # Add a progress history record. + + db.add( + ProgressUpdate( + task_assignment_id=task1_assignment.id, + percentage=33, + comment="Completed the first onboarding section.", + ) + ) + + # ======================================================== + # COMMIT + # ======================================================== + + db.commit() + + # ======================================================== + # OUTPUT + # ======================================================== + + print() + print("Seed complete!") + print() + + print("Manager:") + print( + " email = manager@noticeboardtracker.dev" + ) + print( + " password = Manager123!" + ) + print( + f" id = {manager.id}" + ) + + print() + + print("Cohort:") + print( + f" name = {cohort.name}" + ) + print( + f" id = {cohort.id}" + ) + + print() + + print("Trainees:") + + for trainee in trainees: + print( + f" {trainee.email} / Trainee123! " + f"(id={trainee.id}, " + f"manager_id={manager.id}, " + f"cohort_id={cohort.id})" + ) + + print() + + print("Tasks:") + + print( + f" Task {task1.id}: {task1.title}" + ) + + for subtask in task1_subtasks: + print( + f" - {subtask.title}" + ) + + print() + + print( + f" Task {task2.id}: {task2.title}" + ) + + for subtask in task2_subtasks: + print( + f" - {subtask.title}" + ) + + print() + + print("Assignments:") + print(" Both tasks assigned to both trainees.") + + print() + print("Sample progress:") + print(" Trainee 1 -> Task 1 -> 33%") + print(" Trainee 1 -> Task 1 -> first subtask completed") + print() + + +def run() -> None: + db = SessionLocal() + + try: + clear_database(db) + seed_database(db) + + except Exception: + db.rollback() + raise + + finally: + db.close() + + +if __name__ == "__main__": + run() diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json new file mode 100644 index 00000000..12550782 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md new file mode 100644 index 00000000..d937833b --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html new file mode 100644 index 00000000..9241e0ff --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + task-board-frontend + + +
+ + + diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json new file mode 100644 index 00000000..397902c6 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package-lock.json @@ -0,0 +1,1416 @@ +{ + "name": "task-board-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "task-board-frontend", + "version": "0.0.0", + "dependencies": { + "@reduxjs/toolkit": "^2.12.0", + "framer-motion": "^13.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-redux": "^9.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "vite": "^8.2.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz", + "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz", + "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz", + "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz", + "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz", + "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz", + "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz", + "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz", + "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz", + "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz", + "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz", + "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz", + "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz", + "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz", + "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz", + "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz", + "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz", + "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz", + "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz", + "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", + "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.1.1", + "motion-utils": "^13.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/motion-dom": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", + "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.0.0" + } + }, + "node_modules/motion-utils": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz", + "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.80.0", + "@oxlint/binding-android-arm64": "1.80.0", + "@oxlint/binding-darwin-arm64": "1.80.0", + "@oxlint/binding-darwin-x64": "1.80.0", + "@oxlint/binding-freebsd-x64": "1.80.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", + "@oxlint/binding-linux-arm-musleabihf": "1.80.0", + "@oxlint/binding-linux-arm64-gnu": "1.80.0", + "@oxlint/binding-linux-arm64-musl": "1.80.0", + "@oxlint/binding-linux-ppc64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-musl": "1.80.0", + "@oxlint/binding-linux-s390x-gnu": "1.80.0", + "@oxlint/binding-linux-x64-gnu": "1.80.0", + "@oxlint/binding-linux-x64-musl": "1.80.0", + "@oxlint/binding-openharmony-arm64": "1.80.0", + "@oxlint/binding-win32-arm64-msvc": "1.80.0", + "@oxlint/binding-win32-ia32-msvc": "1.80.0", + "@oxlint/binding-win32-x64-msvc": "1.80.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json new file mode 100644 index 00000000..8d3c8bf8 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "task-board-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.12.0", + "framer-motion": "^13.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-redux": "^9.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "vite": "^8.2.2" + } +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css new file mode 100644 index 00000000..1869651d --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.css @@ -0,0 +1,60 @@ +/* Main application layout */ + +.app-layout { + display: flex; + width: 100%; + min-height: 100vh; + background: var(--bg); +} + +/* Sidebar stays visible while the right side scrolls */ + +.app-layout > aside { + position: sticky; + top: 0; + height: 100vh; + flex-shrink: 0; + overflow-y: auto; +} + +/* Right side of the sidebar */ + +.app-main { + flex: 1; + min-width: 0; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Main page content */ + +.main-content { + flex: 1; + padding: 32px; + background: var(--bg); + box-sizing: border-box; +} + +/* Mobile */ + +@media (max-width: 768px) { + .app-layout { + flex-direction: column; + } + + .app-layout > aside { + position: static; + width: 100%; + height: auto; + overflow: visible; + } + + .app-main { + width: 100%; + } + + .main-content { + padding: 20px; + } +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx new file mode 100644 index 00000000..2f5b971c --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/App.jsx @@ -0,0 +1,151 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import Login from "./components/Login"; + +import TraineeHeader from "./components/Trainee/TraineeHeader"; +import TraineeSidebar from "./components/Trainee/TraineeSidebar"; +import TraineeTasks from "./components/Trainee/TraineeTasks"; + +import TrainerHeader from "./components/Trainer/TrainerHeader"; +import TrainerSidebar from "./components/Trainer/TrainerSidebar"; +import TrainerTasks from "./components/Trainer/TrainerTasks"; +import TrainerCohorts from "./components/Trainer/TrainerCohorts"; +import TrainerTrainees from "./components/Trainer/TrainerTrainees"; + +import { logout, setName as setTraineeName } from "./store/TraineeSlice"; +import { setName as setTrainerName } from "./store/TrainerSlice"; + +import useTheme from "./hooks/useTheme"; + +import "./App.css"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api"; + +function App() { + const dispatch = useDispatch(); + + // ============================================================ + // Authentication + // ============================================================ + + const { accessToken, tokenType, role, isAuthenticated } = useSelector( + (state) => state.trainee, + ); + + // ============================================================ + // Theme (light/dark) - shared regardless of role, toggled from + // whichever sidebar is showing + // ============================================================ + + const { theme, toggleTheme } = useTheme(); + + // ============================================================ + // Trainer navigation + // ============================================================ + + const trainerTab = useSelector((state) => state.trainer.tab); + + // ============================================================ + // GET /api/auth/me + // ============================================================ + + useEffect(() => { + if (!isAuthenticated || !accessToken) return; + + async function fetchProfile() { + try { + const response = await fetch(`${API_BASE_URL}/auth/me`, { + headers: { + Authorization: `${tokenType || "Bearer"} ${accessToken}`, + }, + }); + + if (!response.ok) return; + + const data = await response.json(); + + if (data.role === "manager") { + dispatch(setTrainerName(data.fullName)); + } else { + dispatch(setTraineeName(data.fullName)); + } + } catch (err) { + console.error("Error fetching profile:", err); + } + } + + fetchProfile(); + }, [isAuthenticated, accessToken, tokenType, dispatch]); + + // ============================================================ + // Logout + // ============================================================ + + function handleLogout() { + dispatch(logout()); + } + + // ============================================================ + // Login + // ============================================================ + + if (!isAuthenticated) { + return ; + } + + // ============================================================ + // Determine role + // ============================================================ + + const isManager = role === "manager"; + + // ============================================================ + // Trainer content + // ============================================================ + + function renderTrainerContent() { + switch (trainerTab) { + case "cohorts": + return ; + + case "trainees": + return ; + + case "tasks": + return ; + + default: + return ; + } + } + + // ============================================================ + // Render + // ============================================================ + + return ( +
+ {isManager ? ( + + ) : ( + + )} + +
+ {isManager ? : } + +
+ {isManager ? renderTrainerContent() : } +
+
+
+ ); +} + +export default App; diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png new file mode 100644 index 00000000..02251f4b Binary files /dev/null and b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/hero.png differ diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx new file mode 100644 index 00000000..1884f016 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.jsx @@ -0,0 +1,201 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; +import styles from "./Login.module.css"; + +import { setCredentials } from "../store/TraineeSlice"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api"; + +export default function Login({ onLogin }) { + const dispatch = useDispatch(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event) { + event.preventDefault(); + + setLoading(true); + setError(null); + + try { + console.log("========== LOGIN REQUEST =========="); + + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + + headers: { + "Content-Type": "application/json", + }, + + body: JSON.stringify({ + email, + password, + }), + }); + + console.log("Status:", response.status); + console.log("OK:", response.ok); + + const data = await response.json(); + + console.log("Response Data:", data); + + // -------------------------------- + // Handle errors + // -------------------------------- + + if (!response.ok) { + if (Array.isArray(data.detail)) { + const messages = data.detail + .map((validationError) => { + const field = + validationError.loc?.[validationError.loc.length - 1]; + + const message = validationError.msg || "Invalid value"; + + return field ? `${field}: ${message}` : message; + }) + .join(", "); + + throw new Error(messages); + } + + if (typeof data.detail === "string") { + throw new Error(data.detail); + } + + throw new Error(`Login failed with status ${response.status}`); + } + + // -------------------------------- + // Validate login response + // -------------------------------- + + if (!data.accessToken) { + throw new Error("Login succeeded, but no access token was returned."); + } + + if (!data.role) { + throw new Error("Login succeeded, but no role was returned."); + } + + if (!data.userId) { + throw new Error("Login succeeded, but no user ID was returned."); + } + + // -------------------------------- + // Save login information to Redux + // -------------------------------- + + dispatch( + setCredentials({ + accessToken: data.accessToken, + tokenType: data.tokenType, + role: data.role, + userId: data.userId, + }), + ); + + console.log("========== LOGIN SUCCESS =========="); + console.log("User ID:", data.userId); + console.log("Role:", data.role); + console.log("Token Type:", data.tokenType); + console.log("Access Token Received:", !!data.accessToken); + console.log("==================================="); + + // -------------------------------- + // Tell App.jsx login succeeded + // -------------------------------- + + if (onLogin) { + onLogin(); + } + } catch (error) { + console.error("========== LOGIN ERROR =========="); + console.error(error); + console.error("================================="); + + setError(error instanceof Error ? error.message : "Unable to log in."); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* Header */} + +
+

C.B.M.H.

+ +

Welcome Back

+ +

Sign in to access your trainee task board.

+
+ + {/* Login Form */} + +
+ {/* Email */} + +
+ + + setEmail(event.target.value)} + placeholder="Enter your email" + autoComplete="email" + required + /> +
+ + {/* Password */} + +
+ + + setPassword(event.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + required + /> +
+ + {/* Error */} + + {error && ( +
+ {error} +
+ )} + + {/* Submit */} + + +
+
+
+ ); +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css new file mode 100644 index 00000000..785568d0 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Login.module.css @@ -0,0 +1,105 @@ +/* Deliberately not themed - the login screen always renders light, + regardless of whichever theme was last toggled (which persists in + localStorage independent of being logged in). Colors below are the + light-theme token values from theme.css, hardcoded rather than + referenced via var(), so a returning dark-mode user doesn't land + on a dark login page before they've even signed in. */ + +.loginContainer { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: #f2ead9; +} + +.loginCard { + width: 100%; + max-width: 420px; + padding: 2.5rem; + border: 1px solid #d6c4a1; + border-radius: 12px; + background: #fdf9f0; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); +} + +.header { + margin-bottom: 2rem; + text-align: center; +} + +.label { + margin: 0 0 0.5rem; + color: #6d7f45; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.1em; +} + +.header h1 { + margin: 0 0 0.5rem; + color: #241a10; +} + +.header p:last-child { + margin: 0; + color: #7c6a51; +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.inputGroup label { + color: #3b2f22; + font-weight: 600; +} + +.inputGroup input { + padding: 0.8rem; + border: 1px solid #d6c4a1; + border-radius: 6px; + background: #fdf9f0; + color: #3b2f22; + font-size: 1rem; +} + +.inputGroup input:focus { + outline: none; + border-color: #6d7f45; +} + +/* Only buttons inside this Login component */ +.loginCard button { + width: 100%; + padding: 0.85rem; + border: none; + border-radius: 6px; + background: #6d7f45; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} + +.loginCard button:hover { + background: #576636; +} + +.loginCard button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.error { + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px solid #d3a293; + border-radius: 6px; + background: #f3e0da; + color: #a13f34; +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx new file mode 100644 index 00000000..1ef815f1 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.jsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useDispatch, useSelector } from "react-redux"; +import styles from "./ProfileModal.module.css"; + +import { setName as setTraineeName } from "../store/TraineeSlice"; +import { setName as setTrainerName } from "../store/TrainerSlice"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api"; + +// Backend only stores a single full_name column - split/join here +// rather than migrating to first_name/last_name, which would touch +// every place that already reads full_name (task progress views, +// trainee lists, the seed script, etc). +function splitName(fullName) { + const trimmed = (fullName || "").trim(); + + if (!trimmed) { + return { firstName: "", lastName: "" }; + } + + const [first, ...rest] = trimmed.split(" "); + + return { firstName: first, lastName: rest.join(" ") }; +} + +export default function ProfileModal({ onClose }) { + const dispatch = useDispatch(); + + const { accessToken, tokenType, role } = useSelector( + (state) => state.trainee, + ); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + function getAuthHeaders() { + return { + Authorization: `${tokenType || "Bearer"} ${accessToken}`, + "Content-Type": "application/json", + }; + } + + useEffect(() => { + async function fetchProfile() { + try { + setLoading(true); + setError(null); + + const response = await fetch(`${API_BASE_URL}/auth/me`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("Failed to load your profile."); + } + + const data = await response.json(); + const { firstName: fn, lastName: ln } = splitName(data.fullName); + + setFirstName(fn); + setLastName(ln); + setEmail(data.email || ""); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load profile."); + } finally { + setLoading(false); + } + } + + fetchProfile(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleSubmit(event) { + event.preventDefault(); + + setError(null); + setSuccess(false); + + if (newPassword && newPassword !== confirmPassword) { + setError("New password and confirmation don't match."); + return; + } + + if (newPassword && !currentPassword) { + setError("Enter your current password to set a new one."); + return; + } + + try { + setSaving(true); + + const payload = { + fullName: `${firstName.trim()} ${lastName.trim()}`.trim(), + email: email.trim(), + }; + + if (newPassword) { + payload.currentPassword = currentPassword; + payload.newPassword = newPassword; + } + + const response = await fetch(`${API_BASE_URL}/auth/me`, { + method: "PUT", + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || "Failed to update your profile."); + } + + if (role === "manager") { + dispatch(setTrainerName(data.fullName)); + } else { + dispatch(setTraineeName(data.fullName)); + } + + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setSuccess(true); + } catch (err) { + setError( + err instanceof Error ? err.message : "Unable to update your profile.", + ); + } finally { + setSaving(false); + } + } + + return createPortal( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="profile-modal-title" + > +
+

Profile Settings

+ + +
+ + {loading ? ( +

Loading your profile...

+ ) : ( +
+
+
+ + + setFirstName(event.target.value)} + required + /> +
+ +
+ + + setLastName(event.target.value)} + /> +
+
+ +
+ + + setEmail(event.target.value)} + required + /> +
+ +
+ Change Password (optional) +
+ +
+ + + setCurrentPassword(event.target.value)} + autoComplete="current-password" + placeholder="Only required if setting a new password" + /> +
+ +
+
+ + + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + /> +
+ +
+ + + setConfirmPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + {success &&
Profile updated.
} + +
+ + + +
+
+ )} +
+
, + document.body, + ); +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css new file mode 100644 index 00000000..703002d7 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/ProfileModal.module.css @@ -0,0 +1,190 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 100; + + display: flex; + align-items: center; + justify-content: center; + + padding: 1.5rem; + + background: rgba(15, 23, 42, 0.5); +} + +.modal { + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + + padding: 1.75rem; + + border: 1px solid var(--border); + border-radius: 12px; + + background: var(--surface); + color: var(--text); + + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.header h2 { + margin: 0; + color: var(--text-strong); + font-size: 1.3rem; +} + +.closeButton { + padding: 0.25rem 0.6rem; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; +} + +.closeButton:hover { + background: var(--surface-alt); + color: var(--text); +} + +.loading { + margin: 0; + color: var(--text-muted); +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.field label { + color: var(--text); + font-size: 0.85rem; + font-weight: 600; +} + +.field input { + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-size: 0.9rem; + font-family: inherit; +} + +.field input:focus { + outline: none; + border-color: var(--accent-trainer); +} + +.divider { + display: flex; + align-items: center; + gap: 0.75rem; + + margin: 0.25rem 0; + + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +.error { + padding: 0.65rem 0.8rem; + border: 1px solid var(--danger-border); + border-radius: 6px; + background: var(--danger-bg); + color: var(--danger); + font-size: 0.85rem; +} + +.success { + padding: 0.65rem 0.8rem; + border: 1px solid var(--accent-trainee); + border-radius: 6px; + background: var(--surface-alt); + color: var(--accent-trainee); + font-size: 0.85rem; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.25rem; +} + +.cancelButton { + padding: 0.6rem 1.1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-size: 0.9rem; + cursor: pointer; +} + +.cancelButton:hover { + background: var(--surface-alt); +} + +.saveButton { + padding: 0.6rem 1.3rem; + border: none; + border-radius: 6px; + background: var(--accent-trainer); + color: #ffffff; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.saveButton:hover { + background: var(--accent-trainer-hover); +} + +.saveButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +@media (max-width: 480px) { + .row { + grid-template-columns: 1fr; + } +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx new file mode 100644 index 00000000..b6250abc --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.jsx @@ -0,0 +1,35 @@ +import styles from "./TraineeHeader.module.css"; + +import { useSelector } from "react-redux"; + +export default function TraineeHeader() { + const trainee = useSelector((state) => state.trainee); + + const traineeName = trainee.name; + const userId = trainee.userId; + + return ( +
+
+

NEW TRAINEE

+ +

+ Welcome to C.B.M.H. +

+ +

+ {traineeName + ? `Welcome, ${traineeName}!` + : userId + ? `Welcome, Trainee #${userId}!` + : "Welcome!"} +

+ +

+ Your trainee task board is ready. View your tasks, track your + progress, and stay organized throughout your training. +

+
+
+ ); +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css new file mode 100644 index 00000000..d514ce99 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeHeader.module.css @@ -0,0 +1,48 @@ +.traineeHeader { + width: 100%; + padding: 2rem; + border-radius: 12px; + background-color: var(--surface); + border: 1px solid var(--border); + box-sizing: border-box; +} + +.headerContent { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.welcomeLabel { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.traineeHeader h1 { + margin: 0; + color: var(--text-strong); + font-size: 2rem; + font-weight: 700; +} + +.traineeHeader h1 span { + color: var(--accent-trainee); +} + +.traineeName { + margin: 0.25rem 0 0; + color: var(--text); + font-size: 1.1rem; + font-weight: 600; +} + +.description { + max-width: 650px; + margin: 0.25rem 0 0; + color: var(--text-muted); + font-size: 0.95rem; + line-height: 1.5; +} diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css new file mode 100644 index 00000000..334c897b --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeSidebar.css @@ -0,0 +1,169 @@ +.trainee-sidebar { + width: 260px; + height: 100vh; + background: var(--sidebar-bg); + color: var(--sidebar-text); + display: flex; + flex-direction: column; + padding: 24px 16px; + box-sizing: border-box; +} + +.sidebar-header { + padding: 0 12px 24px; + border-bottom: 1px solid var(--sidebar-border); +} + +.sidebar-header h2 { + margin: 0; + font-size: 22px; + font-weight: 700; + text-transform: capitalize; +} + +/* Trainee profile */ + +.trainee-profile { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 12px; + border-bottom: 1px solid var(--sidebar-border); +} + +.trainee-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--accent-trainer); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; +} + +.trainee-details { + min-width: 0; +} + +.trainee-details h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trainee-details span { + display: block; + margin-top: 3px; + color: var(--sidebar-text-muted); + font-size: 13px; +} + +/* Navigation */ + +.sidebar-navigation { + flex: 1; + padding-top: 24px; +} + +.navigation-title { + margin: 0 12px 10px; + color: var(--sidebar-text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.sidebar-link { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 11px 12px; + margin-bottom: 5px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--sidebar-text); + text-decoration: none; + font-family: inherit; + font-size: 14px; + box-sizing: border-box; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +/* Reset native + + + + + + + {profileOpen && ( + setProfileOpen(false)} /> + )} + + ); +}; + +export default TraineeSidebar; diff --git a/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeTasks.jsx b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeTasks.jsx new file mode 100644 index 00000000..f4ed63b2 --- /dev/null +++ b/workshops/fullstack-aws/projects/submission/Group3/frontend/task-board-frontend/src/components/Trainee/TraineeTasks.jsx @@ -0,0 +1,840 @@ +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { AnimatePresence, motion } from "framer-motion"; +import styles from "./TraineeTasks.module.css"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8000/api"; + +export default function TraineeTasks() { + // ============================================================ + // Redux authentication + // ============================================================ + + const { accessToken, tokenType, userId, role, isAuthenticated } = useSelector( + (state) => state.trainee, + ); + + // ============================================================ + // Local state + // ============================================================ + + const [tasks, setTasks] = useState([]); + const [selectedTask, setSelectedTask] = useState(null); + const [progressHistory, setProgressHistory] = useState([]); + + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + + const [subtaskLoading, setSubtaskLoading] = useState({}); + const [progressSubmitting, setProgressSubmitting] = useState(false); + + const [error, setError] = useState(null); + + const [progressComment, setProgressComment] = useState(""); + + // ============================================================ + // Authentication headers + // ============================================================ + + function getAuthHeaders() { + if (!accessToken) { + throw new Error("You are not logged in."); + } + + return { + Authorization: `${tokenType || "Bearer"} ${accessToken}`, + "Content-Type": "application/json", + }; + } + + // ============================================================ + // Handle API responses + // ============================================================ + + async function handleResponse(response, defaultMessage) { + if (response.status === 401) { + throw new Error("Your session has expired. Please log in again."); + } + + if (!response.ok) { + let message = defaultMessage; + + try { + const errorData = await response.json(); + + if (typeof errorData?.detail === "string") { + message = errorData.detail; + } else if (typeof errorData?.message === "string") { + message = errorData.message; + } + } catch { + // Response wasn't JSON. + } + + throw new Error(message); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get("content-type"); + + if (contentType?.includes("application/json")) { + return response.json(); + } + + return null; + } + + // ============================================================ + // Calculate percentage from subtasks + // + // Example: + // + // 0 / 3 = 0% + // 1 / 3 = 33% + // 2 / 3 = 67% + // 3 / 3 = 100% + // ============================================================ + + function calculateProgress(subtasks = []) { + if (!subtasks.length) { + return 0; + } + + const completedCount = subtasks.filter( + (subtask) => subtask.isCompleted, + ).length; + + return Math.round((completedCount / subtasks.length) * 100); + } + + // ============================================================ + // GET /student/tasks + // ============================================================ + + useEffect(() => { + async function fetchTasks() { + try { + setLoading(true); + setError(null); + + if (!isAuthenticated || !accessToken) { + throw new Error("You are not logged in."); + } + + const response = await fetch(`${API_BASE_URL}/student/tasks`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await handleResponse( + response, + `Failed to fetch tasks: ${response.status}`, + ); + + setTasks(Array.isArray(data) ? data : []); + } catch (error) { + console.error("Error fetching trainee tasks:", error); + + setError( + error instanceof Error ? error.message : "Unable to load tasks.", + ); + } finally { + setLoading(false); + } + } + + fetchTasks(); + }, [accessToken, tokenType, userId, role, isAuthenticated]); + + // ============================================================ + // GET /student/tasks/{task_id} + // ============================================================ + + async function fetchTask(taskId) { + try { + setDetailLoading(true); + setError(null); + + const response = await fetch(`${API_BASE_URL}/student/tasks/${taskId}`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await handleResponse( + response, + `Failed to fetch task: ${response.status}`, + ); + + setSelectedTask(data); + + // The percentage is calculated from the subtasks. + // We intentionally don't use an editable percentage. + await fetchProgressHistory(taskId); + } catch (error) { + console.error("Error fetching task:", error); + + setError(error instanceof Error ? error.message : "Unable to load task."); + } finally { + setDetailLoading(false); + } + } + + // ============================================================ + // Open / close task + // ============================================================ + + function handleTaskClick(taskId) { + // Clicking the open task closes it. + if (selectedTask?.id === taskId) { + setSelectedTask(null); + setProgressHistory([]); + setProgressComment(""); + setError(null); + + return; + } + + fetchTask(taskId); + } + + // ============================================================ + // GET /student/tasks/{task_id}/progress/history + // ============================================================ + + async function fetchProgressHistory(taskId) { + try { + const response = await fetch( + `${API_BASE_URL}/student/tasks/${taskId}/progress/history`, + { + method: "GET", + headers: getAuthHeaders(), + }, + ); + + const data = await handleResponse( + response, + `Failed to fetch progress history: ${response.status}`, + ); + + setProgressHistory(Array.isArray(data) ? data : []); + } catch (error) { + console.error("Error fetching progress history:", error); + + setError( + error instanceof Error + ? error.message + : "Unable to load progress history.", + ); + } + } + + // ============================================================ + // POST /student/tasks/{task_id}/progress + // + // Percentage is calculated automatically from subtasks. + // The trainee only supplies an optional comment. + // ============================================================ + + async function submitProgress(task, percentage, comment = null) { + if (!task?.id) { + return; + } + + try { + setProgressSubmitting(true); + setError(null); + + const response = await fetch( + `${API_BASE_URL}/student/tasks/${task.id}/progress`, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + percentage, + comment: comment?.trim() || null, + }), + }, + ); + + await handleResponse( + response, + `Failed to submit progress: ${response.status}`, + ); + + // Refresh progress history so the new entry appears. + await fetchProgressHistory(task.id); + + setProgressComment(""); + } catch (error) { + console.error("Error submitting progress:", error); + + setError( + error instanceof Error ? error.message : "Unable to submit progress.", + ); + } finally { + setProgressSubmitting(false); + } + } + + // ============================================================ + // PATCH /student/subtasks/{subtask_id} + // + // Clicking a checkbox immediately sends: + // + // { + // "isCompleted": true/false + // } + // + // Then we calculate the new percentage from the subtasks. + // ============================================================ + + async function handleSubtaskToggle(subtask) { + if (!selectedTask) { + return; + } + + const subtaskId = subtask.id; + + if (subtaskLoading[subtaskId]) { + return; + } + + const newCompletedState = !subtask.isCompleted; + + try { + setError(null); + + setSubtaskLoading((current) => ({ + ...current, + [subtaskId]: true, + })); + + // ---------------------------------------------------------- + // PATCH the subtask + // ---------------------------------------------------------- + + const response = await fetch( + `${API_BASE_URL}/student/subtasks/${subtaskId}`, + { + method: "PATCH", + headers: getAuthHeaders(), + body: JSON.stringify({ + isCompleted: newCompletedState, + }), + }, + ); + + await handleResponse( + response, + `Failed to update subtask: ${response.status}`, + ); + + // ---------------------------------------------------------- + // Refresh the task from the backend + // ---------------------------------------------------------- + + const taskResponse = await fetch( + `${API_BASE_URL}/student/tasks/${selectedTask.id}`, + { + method: "GET", + headers: getAuthHeaders(), + }, + ); + + const updatedTask = await handleResponse( + taskResponse, + `Failed to refresh task: ${taskResponse.status}`, + ); + + setSelectedTask(updatedTask); + + // ---------------------------------------------------------- + // Calculate progress from subtasks + // ---------------------------------------------------------- + + const calculatedPercentage = calculateProgress(updatedTask.subtasks); + + // ---------------------------------------------------------- + // Update the task card in the list + // ---------------------------------------------------------- + + setTasks((currentTasks) => + currentTasks.map((task) => + task.taskId === selectedTask.id + ? { + ...task, + currentPercentage: calculatedPercentage, + subtaskSummary: `${ + updatedTask.subtasks.filter((item) => item.isCompleted).length + }/${updatedTask.subtasks.length} completed`, + } + : task, + ), + ); + + // ---------------------------------------------------------- + // Submit calculated progress to the backend + // + // We don't ask the trainee for a percentage. + // ---------------------------------------------------------- + + await submitProgress(updatedTask, calculatedPercentage, null); + } catch (error) { + console.error("Error updating subtask:", error); + + setError( + error instanceof Error ? error.message : "Unable to update subtask.", + ); + } finally { + setSubtaskLoading((current) => { + const next = { ...current }; + delete next[subtaskId]; + return next; + }); + } + } + + // ============================================================ + // Submit an optional progress comment + // + // Percentage is still calculated from subtasks. + // ============================================================ + + async function handleProgressCommentSubmit(event) { + event.preventDefault(); + + if (!selectedTask) { + return; + } + + const percentage = calculateProgress(selectedTask.subtasks); + + await submitProgress(selectedTask, percentage, progressComment); + } + + // ============================================================ + // Loading + // ============================================================ + + if (loading) { + return ( +
+

Loading trainee tasks...

+
+ ); + } + + // ============================================================ + // Initial error + // ============================================================ + + if (error && !selectedTask) { + return ( +
+

Unable to load tasks

+

{error}

+
+ ); + } + + // ============================================================ + // Render + // ============================================================ + + return ( +
+ {/* ====================================================== + HEADER + ====================================================== */} + +
+
+

TRAINEE TASK BOARD

+

My Tasks

+
+ +

+ {tasks.length} {tasks.length === 1 ? "task" : "tasks"} +

+
+ + {/* ====================================================== + ERROR + ====================================================== */} + + {error && selectedTask && ( +
+

{error}

+
+ )} + + {/* ====================================================== + TASK LIST + ====================================================== */} + +
+ {tasks.length === 0 ? ( +
+

No tasks assigned

+

You currently don't have any tasks to complete.

+
+ ) : ( + tasks.map((task) => { + const isOpen = selectedTask?.id === task.taskId; + + const progress = isOpen + ? calculateProgress(selectedTask.subtasks) + : (task.currentPercentage ?? 0); + + return ( +
+ {/* ================================================== + TASK SUMMARY + ================================================== */} + + + + {/* ================================================== + EXPANDABLE DETAILS + ================================================== */} + + + {isOpen && ( + +
+ {detailLoading ? ( +
+

Loading task...

+
+ ) : ( + <> + {/* ---------------------------------------- + Description + ---------------------------------------- */} + + {selectedTask.description && ( +
+

+ Description +

+ +

+ {selectedTask.description} +

+
+ )} + + {/* ---------------------------------------- + Task information + ---------------------------------------- */} + +
+ {selectedTask.dueDate && ( +
+ + Due Date + + + + {new Date( + selectedTask.dueDate, + ).toLocaleDateString()} + +
+ )} + +
+ + Completion + + + {progress}% +
+
+ + {/* ---------------------------------------- + Progress bar + ---------------------------------------- */} + +
+
+ Task Completion + + {progress}% +
+ +
+ +
+
+ + {/* ---------------------------------------- + Subtasks + ---------------------------------------- */} + + {selectedTask.subtasks && + selectedTask.subtasks.length > 0 && ( +
+
+

Subtasks

+ + + { + selectedTask.subtasks.filter( + (subtask) => subtask.isCompleted, + ).length + }{" "} + / {selectedTask.subtasks.length} + +
+ +
+ {selectedTask.subtasks.map((subtask) => { + const isUpdating = + subtaskLoading[subtask.id]; + + return ( + + ); + })} +
+
+ )} + + {/* ---------------------------------------- + No subtasks + ---------------------------------------- */} + + {(!selectedTask.subtasks || + selectedTask.subtasks.length === 0) && ( +
+

This task doesn't have any subtasks yet.

+
+ )} + + {/* ---------------------------------------- + Progress comment + ---------------------------------------- */} + +
+
+
+

Progress Update

+ +

+ Completion is automatically calculated from + your subtasks. +

+
+ + + {progress}% + +
+ +
+