diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7e324eb04..de3e655e8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,7 +2,7 @@ name: Rust on: pull_request: - branches: [main, "renovate/*"] + branches: [main, "renovate/*", "CHAOS-224-KHAOS-rewrite"] push: branches: ["renovate/*"] @@ -12,34 +12,61 @@ env: jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: chaos + ports: + - 5432:5432 steps: - uses: actions/checkout@v4 - - name: Mock an env file + - name: Setup env file run: | - echo "DATABASE_URL=test_url" >> backend/.env + echo "DATABASE_URL=postgres://postgres:password@localhost:5432/chaos" >> backend/.env echo "JWT_SECRET=test_secret" >> backend/.env echo "GOOGLE_CLIENT_ID=test" >> backend/.env echo "GOOGLE_CLIENT_SECRET=test" >> backend/.env echo "GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback" >> backend/.env - echo "ROCKET_DATABASES='{}'" >> backend/.env + echo "S3_BUCKET_NAME=chaos-storage" >> backend/.env + echo "S3_ACCESS_KEY=test_access_key" >> backend/.env + echo "S3_SECRET_KEY=test_secret_key" >> backend/.env + echo "S3_ENDPOINT=https://chaos-storage.s3.ap-southeast-1.amazonaws.com" >> backend/.env + echo "S3_REGION_NAME=ap-southeast-1" >> backend/.env + echo "DEV_ENV=dev" >> backend/.env + echo "SMTP_USERNAME=test_username" >> backend/.env + echo "SMTP_PASSWORD=test_password" >> backend/.env + echo "SMTP_HOST=smtp.example.com" >> backend/.env # selecting a toolchain either by action or manual `rustup` calls should happen # before the plugin, as it uses the current rustc version as its cache key - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable - - uses: Swatinem/rust-cache@v2 + - name: Setup cargo cache + uses: actions/cache@v3 with: - working-directory: backend/ - - name: Build - run: cargo build --manifest-path backend/server/Cargo.toml - - name: Cargo Clippy - run: cargo clippy --manifest-path backend/server/Cargo.toml - - name: RustFmt - run: cargo fmt --manifest-path backend/server/Cargo.toml + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + backend/server/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + - name: Migrate DB + working-directory: backend + run: | + which sqlx || cargo install sqlx-cli --no-default-features --features native-tls,postgres + sqlx database create + sqlx migrate run - name: Build - run: cargo build --manifest-path backend/seed_data/Cargo.toml - - name: Cargo Clippy - run: cargo clippy --manifest-path backend/seed_data/Cargo.toml - - name: RustFmt - run: cargo fmt --manifest-path backend/seed_data/Cargo.toml + working-directory: backend/server + run: cargo build + - name: Database Seeding + working-directory: backend/database-seeding + run: | + cargo build + cargo run diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6a3e68da1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.DS_Store \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 84f023f1e..000000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -.env -target -Dockerfile* \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index e9480c466..8abaee330 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,7 @@ .env target -images +Cargo.lock +prisma-cli/prisma/migrations +/.idea +**/.DS_Store +.env.* \ No newline at end of file diff --git a/backend/Cargo.lock b/backend/Cargo.lock deleted file mode 100644 index 32ab2908d..000000000 --- a/backend/Cargo.lock +++ /dev/null @@ -1,2873 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "aead" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c192eb8f11fc081b0fe4259ba5af04217d4e0faddd02417310a927911abd7c8" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433cfd6710c9986c576a25ca913c39d66a6474107b406f34f91d4a8923395241" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "async-stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" -dependencies = [ - "async-stream-impl", - "futures-core", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "async-trait" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "atomic" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c" -dependencies = [ - "autocfg", -] - -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" - -[[package]] -name = "binascii" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" - -[[package]] -name = "bit_field" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb6dd1c2376d2e096796e234a70e17e94cc2d5d54ff8ce42b28cef1d0d359a4" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "block-buffer" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" - -[[package]] -name = "bytemuck" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5715e491b5a1598fc2bef5a606847b5dc1d48ea625bd3c02c00de8285591da" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" - -[[package]] -name = "cc" -version = "1.0.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" -dependencies = [ - "jobserver", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-integer", - "num-traits", - "serde", - "time 0.1.44", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "cipher" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "cookie" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "344adc371239ef32293cb1c4fe519592fcf21206c79c02854320afcdf3ab4917" -dependencies = [ - "aes-gcm", - "base64 0.13.1", - "hkdf", - "hmac", - "percent-encoding", - "rand", - "sha2", - "subtle", - "time 0.3.17", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "cpufeatures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" -dependencies = [ - "cfg-if", - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f916dfc5d356b0ed9dae65f1db9fc9770aa2851d2662b988ccf4fe3516e86348" -dependencies = [ - "autocfg", - "cfg-if", - "crossbeam-utils", - "memoffset", - "scopeguard", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crunchy" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "rand_core", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "cxx" -version = "1.0.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a41a86530d0fe7f5d9ea779916b7cadd2d4f9add748b99c2c029cbbdfaf453" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06416d667ff3e3ad2df1cd8cd8afae5da26cf9cec4d0825040f88b5ca659a2f0" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn 1.0.103", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "820a9a2af1669deeef27cb271f476ffd196a2c4b6731336011e0ba63e2c7cf71" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a08a6e2fcc370a089ad3b4aaf54db3b1b4cee38ddabce5896b33eb693275f470" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "devise" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c7580b072f1c8476148f16e0a0d5dedddab787da98d86c5082c5e9ed8ab595" -dependencies = [ - "devise_codegen", - "devise_core", -] - -[[package]] -name = "devise_codegen" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "123c73e7a6e51b05c75fe1a1b2f4e241399ea5740ed810b0e3e6cacd9db5e7b2" -dependencies = [ - "devise_core", - "quote", -] - -[[package]] -name = "devise_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841ef46f4787d9097405cac4e70fb8644fc037b526e8c14054247c0263c400d0" -dependencies = [ - "bitflags", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "diesel" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28135ecf6b7d446b43e27e225622a038cc4e2930a1022f51cdb97ada19b8e4d" -dependencies = [ - "bitflags", - "byteorder", - "chrono", - "diesel_derives", - "pq-sys", - "r2d2", -] - -[[package]] -name = "diesel-derive-enum" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8910921b014e2af16298f006de12aa08af894b71f0f49a486ab6d74b17bbed" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "diesel_derives" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f5098f628d02a7a0f68ddba586fb61e80edec3bdc1be3b921f4ceec60858d3" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "diesel_migrations" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3cde8413353dc7f5d72fa8ce0b99a560a359d2c5ef1e5817ca731cd9008f4c" -dependencies = [ - "migrations_internals", - "migrations_macros", -] - -[[package]] -name = "digest" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - -[[package]] -name = "dotenv_codegen" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56966279c10e4f8ee8c22123a15ed74e7c8150b658b26c619c53f4a56eb4a8aa" -dependencies = [ - "dotenv_codegen_implementation", - "proc-macro-hack", -] - -[[package]] -name = "dotenv_codegen_implementation" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e737a3522cd45f6adc19b644ce43ef53e1e9045f2d2de425c1f468abd4cf33" -dependencies = [ - "dotenv", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "either" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" - -[[package]] -name = "encoding_rs" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "exr" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb5f255b5980bb0c8cf676b675d1a99be40f316881444f44e0462eaf5df5ded" -dependencies = [ - "bit_field", - "flume", - "half", - "lebe", - "miniz_oxide 0.6.2", - "smallvec", - "threadpool", -] - -[[package]] -name = "fastrand" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" -dependencies = [ - "instant", -] - -[[package]] -name = "figment" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e56602b469b2201400dec66a66aec5a9b8761ee97cd1b8c96ab2483fcc16cc9" -dependencies = [ - "atomic", - "pear", - "serde", - "serde_json", - "toml", - "uncased", - "version_check", -] - -[[package]] -name = "flate2" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" -dependencies = [ - "crc32fast", - "miniz_oxide 0.5.4", -] - -[[package]] -name = "flume" -version = "0.10.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "pin-project", - "spin 0.9.4", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" - -[[package]] -name = "futures-io" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" - -[[package]] -name = "futures-sink" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" - -[[package]] -name = "futures-task" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" - -[[package]] -name = "futures-util" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generator" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc184cace1cea8335047a471cc1da80f18acf8a76f3bab2028d499e328948ec7" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows", -] - -[[package]] -name = "generic-array" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "ghash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "gif" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "glob" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" - -[[package]] -name = "h2" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad6a9459c9c30b177b925162351f97e7d967c7ea8bab3b8352805327daf45554" -dependencies = [ - "crunchy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "heck" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" - -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "hkdf" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "http" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "httpdate" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" - -[[package]] -name = "hyper" -version = "0.14.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" -dependencies = [ - "cxx", - "cxx-build", -] - -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "image" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8e4fb07cf672b1642304e731ef8a6a4c7891d67bb4fd4f5ce58cd6ed86803c" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "exr", - "gif", - "jpeg-decoder", - "num-rational", - "num-traits", - "png", - "scoped_threadpool", - "tiff", -] - -[[package]] -name = "indexmap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" -dependencies = [ - "autocfg", - "hashbrown", - "serde", -] - -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "ipnet" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f88c5561171189e69df9d98bcf18fd5f9558300f7ea7b801eb8a0fd748bd8745" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" - -[[package]] -name = "jobserver" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" -dependencies = [ - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9478aa10f73e7528198d75109c8be5cd7d15fb530238040148d5f9a22d4c5b3b" -dependencies = [ - "rayon", -] - -[[package]] -name = "js-sys" -version = "0.3.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "8.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" -dependencies = [ - "base64 0.21.2", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "lebe" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" - -[[package]] -name = "libc" -version = "0.2.137" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" - -[[package]] -name = "libwebp-sys" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439fd1885aa28937e7edcd68d2e793cb4a22f8733460d2519fbafd2b215672bf" -dependencies = [ - "cc", -] - -[[package]] -name = "link-cplusplus" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" -dependencies = [ - "cc", -] - -[[package]] -name = "lock_api" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - -[[package]] -name = "migrations_internals" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4fc84e4af020b837029e017966f86a1c2d5e83e64b589963d5047525995860" -dependencies = [ - "diesel", -] - -[[package]] -name = "migrations_macros" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9753f12909fd8d923f75ae5c3258cae1ed3c8ec052e1b38c93c21a6d157f789c" -dependencies = [ - "migrations_internals", - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "mime" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" - -[[package]] -name = "miniz_oxide" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" -dependencies = [ - "adler", -] - -[[package]] -name = "miniz_oxide" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.42.0", -] - -[[package]] -name = "multer" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed4198ce7a4cbd2a57af78d28c6fbb57d81ac5f1d6ad79ac6c5587419cbdf22" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "log", - "memchr", - "mime", - "spin 0.9.4", - "tokio", - "tokio-util", - "version_check", -] - -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom", -] - -[[package]] -name = "native-tls" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" -dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-bigint" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "once_cell" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" - -[[package]] -name = "opaque-debug" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" - -[[package]] -name = "openssl" -version = "0.10.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12fc0523e3bd51a692c8850d075d74dc062ccf251c0110668cbd921917118a13" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "openssl-sys" -version = "0.9.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b84c3b2d099b81f0953422b4d4ad58761589d0229b5506356afca05a3670a" -dependencies = [ - "autocfg", - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-sys 0.42.0", -] - -[[package]] -name = "pear" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15e44241c5e4c868e3eaa78b7c1848cadd6344ed4f54d029832d32b415a58702" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a5ca643c2303ecb740d506539deba189e16f2754040a42901cd8105d0282d0" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "pem" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" -dependencies = [ - "base64 0.13.1", -] - -[[package]] -name = "percent-encoding" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" - -[[package]] -name = "pin-project" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" - -[[package]] -name = "png" -version = "0.17.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f0e7f4c94ec26ff209cee506314212639d6c91b80afb82984819fafce9df01c" -dependencies = [ - "bitflags", - "crc32fast", - "flate2", - "miniz_oxide 0.5.4", -] - -[[package]] -name = "polyval" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "pq-sys" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b845d6d8ec554f972a2c5298aad68953fd64e7441e846075450b44656a016d1" -dependencies = [ - "vcpkg", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" - -[[package]] -name = "proc-macro2" -version = "1.0.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", - "version_check", - "yansi", -] - -[[package]] -name = "quote" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rayon" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd99e5772ead8baa5215278c9b15bf92087709e9c1b2d1f97cdb5a183c933a7d" -dependencies = [ - "autocfg", - "crossbeam-deque", - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-utils", - "num_cpus", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b15debb4f9d60d767cd8ca9ef7abb2452922f3214671ff052defc7f3502c44" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abfa8511e9e94fd3de6585a3d3cd00e01ed556dc9814829280af0e8dc72a8f36" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "regex" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.6.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" - -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - -[[package]] -name = "reqwest" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c" -dependencies = [ - "base64 0.13.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted", - "web-sys", - "winapi", -] - -[[package]] -name = "rocket" -version = "0.5.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ead083fce4a405feb349cf09abdf64471c6077f14e0ce59364aa90d4b99317" -dependencies = [ - "async-stream", - "async-trait", - "atomic", - "atty", - "binascii", - "bytes", - "either", - "figment", - "futures", - "indexmap", - "log", - "memchr", - "multer", - "num_cpus", - "parking_lot", - "pin-project-lite", - "rand", - "ref-cast", - "rocket_codegen", - "rocket_http", - "serde", - "serde_json", - "state", - "tempfile", - "time 0.3.17", - "tokio", - "tokio-stream", - "tokio-util", - "ubyte", - "version_check", - "yansi", -] - -[[package]] -name = "rocket_codegen" -version = "0.5.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aeb6bb9c61e9cd2c00d70ea267bf36f76a4cc615e5908b349c2f9d93999b47" -dependencies = [ - "devise", - "glob", - "indexmap", - "proc-macro2", - "quote", - "rocket_http", - "syn 1.0.103", - "unicode-xid", -] - -[[package]] -name = "rocket_cors" -version = "0.6.0-alpha1" -source = "git+https://github.com/lawliet89/rocket_cors?branch=master#c17e8145baa4790319fdb6a473e465b960f55e7c" -dependencies = [ - "http", - "log", - "regex", - "rocket", - "serde", - "serde_derive", - "unicase", - "unicase_serde", - "url", -] - -[[package]] -name = "rocket_http" -version = "0.5.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ded65d127954de3c12471630bf4b81a2792f065984461e65b91d0fdaafc17a2" -dependencies = [ - "cookie", - "either", - "futures", - "http", - "hyper", - "indexmap", - "log", - "memchr", - "pear", - "percent-encoding", - "pin-project-lite", - "ref-cast", - "serde", - "smallvec", - "stable-pattern", - "state", - "time 0.3.17", - "tokio", - "uncased", -] - -[[package]] -name = "rocket_sync_db_pools" -version = "0.1.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa48b6ab25013e9812f1b0c592741900b3a2a83c0936292e0565c0ac842f558" -dependencies = [ - "diesel", - "r2d2", - "rocket", - "rocket_sync_db_pools_codegen", - "serde", - "tokio", -] - -[[package]] -name = "rocket_sync_db_pools_codegen" -version = "0.1.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280ef2d232923e69cb93da156972eb5476a7cce5ba44843f6608f46a4abf7aab" -dependencies = [ - "devise", - "quote", -] - -[[package]] -name = "rustversion" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" - -[[package]] -name = "ryu" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" - -[[package]] -name = "schannel" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" -dependencies = [ - "lazy_static", - "windows-sys 0.36.1", -] - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977a7519bff143a44f842fd07e80ad1329295bd71686457f18e496736f4bf9bf" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scoped_threadpool" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" - -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "scratch" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" - -[[package]] -name = "security-framework" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "seed_data" -version = "0.1.0" -dependencies = [ - "chrono", - "diesel", - "dotenv", - "server", -] - -[[package]] -name = "serde" -version = "1.0.147" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.147" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "serde_json" -version = "1.0.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "server" -version = "0.1.0" -dependencies = [ - "chrono", - "diesel", - "diesel-derive-enum", - "diesel_migrations", - "dotenv", - "dotenv_codegen", - "figment", - "image", - "itertools", - "jsonwebtoken", - "once_cell", - "reqwest", - "rocket", - "rocket_cors", - "rocket_sync_db_pools", - "serde", - "serde_json", - "strum", - "uuid", - "webp", -] - -[[package]] -name = "sha2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" -dependencies = [ - "libc", -] - -[[package]] -name = "simple_asn1" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time 0.3.17", -] - -[[package]] -name = "slab" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" - -[[package]] -name = "socket2" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6002a767bff9e83f8eeecf883ecb8011875a21ae8da43bffb817a57e78cc09" -dependencies = [ - "lock_api", -] - -[[package]] -name = "stable-pattern" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" -dependencies = [ - "memchr", -] - -[[package]] -name = "state" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" -dependencies = [ - "loom", -] - -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.103", -] - -[[package]] -name = "subtle" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" - -[[package]] -name = "syn" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" -dependencies = [ - "cfg-if", - "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", -] - -[[package]] -name = "termcolor" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "thread_local" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" -dependencies = [ - "once_cell", -] - -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] - -[[package]] -name = "tiff" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259662e32d1e219321eb309d5f9d898b779769d81b76e762c07c8e5d38fcb65" -dependencies = [ - "flate2", - "jpeg-decoder", - "weezl", -] - -[[package]] -name = "time" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - -[[package]] -name = "time" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" -dependencies = [ - "itoa", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" - -[[package]] -name = "time-macros" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" -dependencies = [ - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" - -[[package]] -name = "tokio" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76ce4a75fb488c605c54bf610f221cea8b0dafb53333c1a67e8ee199dcd2ae3" -dependencies = [ - "autocfg", - "bytes", - "libc", - "memchr", - "mio", - "num_cpus", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "winapi", -] - -[[package]] -name = "tokio-macros" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" -dependencies = [ - "serde", -] - -[[package]] -name = "tower-service" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" - -[[package]] -name = "tracing" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" -dependencies = [ - "cfg-if", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", -] - -[[package]] -name = "tracing-core" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" -dependencies = [ - "lazy_static", - "log", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" - -[[package]] -name = "typenum" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" - -[[package]] -name = "ubyte" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c81f0dae7d286ad0d9366d7679a77934cfc3cf3a8d67e82669794412b2368fe6" -dependencies = [ - "serde", -] - -[[package]] -name = "uncased" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622" -dependencies = [ - "serde", - "version_check", -] - -[[package]] -name = "unicase" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" -dependencies = [ - "version_check", -] - -[[package]] -name = "unicase_serde" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef53697679d874d69f3160af80bc28de12730a985d57bdf2b47456ccb8b11f1" -dependencies = [ - "serde", - "unicase", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" - -[[package]] -name = "unicode-ident" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - -[[package]] -name = "unicode-xid" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" - -[[package]] -name = "universal-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3160b73c9a19f7e2939a2fdad446c57c1bbbbf4d919d3213ff1267a580d8b5" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "url" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "uuid" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2" -dependencies = [ - "getrandom", - "rand", - "uuid-macro-internal", -] - -[[package]] -name = "uuid-macro-internal" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f67b459f42af2e6e1ee213cb9da4dbd022d3320788c3fb3e1b893093f1e45da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.18", -] - -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "want" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" -dependencies = [ - "log", - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 1.0.103", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.103", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" - -[[package]] -name = "web-sys" -version = "0.3.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webp" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf022f821f166079a407d000ab57e84de020e66ffbbf4edde999bc7d6e371cae" -dependencies = [ - "image", - "libwebp-sys", -] - -[[package]] -name = "weezl" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbedf6db9096bc2364adce0ae0aa636dcd89f3c3f2cd67947062aaf0ca2a10ec" -dependencies = [ - "windows_aarch64_msvc 0.32.0", - "windows_i686_gnu 0.32.0", - "windows_i686_msvc 0.32.0", - "windows_x86_64_gnu 0.32.0", - "windows_x86_64_msvc 0.32.0", -] - -[[package]] -name = "windows-sys" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" -dependencies = [ - "windows_aarch64_msvc 0.36.1", - "windows_i686_gnu 0.36.1", - "windows_i686_msvc 0.36.1", - "windows_x86_64_gnu 0.36.1", - "windows_x86_64_msvc 0.36.1", -] - -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc 0.42.0", - "windows_i686_gnu 0.42.0", - "windows_i686_msvc 0.42.0", - "windows_x86_64_gnu 0.42.0", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc 0.42.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" - -[[package]] -name = "windows_i686_gnu" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" - -[[package]] -name = "windows_i686_gnu" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" - -[[package]] -name = "windows_i686_msvc" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" - -[[package]] -name = "windows_i686_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "yansi" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/backend/Cargo.toml b/backend/Cargo.toml deleted file mode 100644 index b0dc95724..000000000 --- a/backend/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[workspace] - -members = [ - "seed_data", - "server" -] diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 4b5276b75..000000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM lukemathwalker/cargo-chef:latest-rust-1.68.0 AS chef -WORKDIR /app - -FROM chef AS planner -COPY . . -RUN cargo chef prepare --recipe-path recipe.json - -FROM chef AS builder -COPY --from=planner /app/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -COPY . . -RUN cargo build --release --bin server - -FROM ubuntu -RUN apt update -RUN apt install -y wget libpq5 -RUN wget https://mirrors.edge.kernel.org/ubuntu/pool/main/o/openssl/libssl1.1_1.1.0g-2ubuntu4_amd64.deb \ - && dpkg -i libssl1.1_1.1.0g-2ubuntu4_amd64.deb \ - && rm libssl1.1_1.1.0g-2ubuntu4_amd64.deb - -COPY --from=builder /app/target/release/server / -CMD ["./server"] diff --git a/backend/README.md b/backend/README.md index 429197093..ba1db0b41 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,25 +1,64 @@ -# Setup guide - -* Install the latest (stable) Rust toolchain with [rustup](https://rustup.rs/) -* Install postgres with associated dev tools, eg. `sudo apt install postgresql-all` (something like `brew install postgresql` for osx) -* Download and save the backend `.env` file from vault to the root of the backend folder. -* Setup postgres accordingly - * Make sure the user is named `postgres` and the password is the same as the one in vault `.env` file - * the password is in the `DATABSE_URL` field, which will look very similar to `postgres://postgres:@localhost/chaos` -* Install diesel-cli with `cargo install diesel_cli --no-default-features --features postgres` -* Run `diesel setup && diesel migration run` - * If you get a server connection error, check out [this stackoverflow thread](https://stackoverflow.com/questions/32439167/psql-could-not-connect-to-server-connection-refused-error-when-connecting-to) - * this might delete code in the backend, make sure to restore any deletions. -* Run the server with `cargo run --bin server` - * If you want to run the server and get it to restart upon changes, you can install `cargo watch` with `cargo install cargo-watch` - * Then, run `cargo watch -x 'run --bin server'` instead - * It will watch files and continually re-compile upon changes -* If there are errrors, ask on discord. - - -# Scripts - -Scripts should be run from the `chaos/backend` directory: - * `scripts/become_super_user`- will prompt for the email address to turn into a GLOBAL super user - * `scripts/seed.sh` - will wipe your database and add some dummy data - * If you are getting a `bad variable nameread answer` error, ensure the file has LF-style newlines +# CHAOS Backend + +CHAOS' backend is implemented in Rust and for data persistence, we use PostgreSQL. + +## Table of Contents + +- [Dev Setup](#dev-setup) +- [Code Structure](#code-structure) +- [Tech Stack](#tech-stack) + + +## Dev Setup + +To run the backend in a dev/testing environment: +1. Install `docker-compose` (see [official installation guide](https://docs.docker.com/compose/install/)). +2. Navigate to the directory this file is in (`backend`) in your terminal (not `backend/server`). +3. Possibly terminate any running instances of postgres, as the dockerized postgres we will spawn uses the same default port, so the two might interefere with each other. +4. Run `./setup-dev-env.sh` (you might have to make it executable before with `chmod +x setup-dev-env.sh`), which should drop you into a new shell that has the required tools installed. +5. Now, you can `cd server` and should be able to `cargo build` successfully. +6. Once you exit out of the newly created shell (e.g. type `exit`, or kill the terminal), the dockerized postgres instance should automatically be torn down, so it's not unnecessarily running in the background all the time. + + +## Code Structure + +### Handler +The handler module takes care of request handling. It implements the framework or library we are using, invokes the +service functions and responds via HTTP with their return values. + +### Middleware +The middleware module contains middlewares, functions that run before or after the function handlers. A common use case +is authorization, where middleware is used to find the userId from the user's token. + +### Models +Models are Rust structs that represent the data. There must be a struct for each table in the database, as well as a +struct to describe the fully joined data. E.g. A campaign struct with a array of questions, even though questions are +stored as rows in a separate table. These models implement all functions that conduct business logic on the respective +entity, and also interact with the database. This separation from request handling makes it easy to swap out any new +form of requests, but reuse the same logic functions. + +### Service +The service module contains all helper functions. For example, functions for determining a user's authorization to +mutate an object are defined here. + +#### Request Path +Request -> Middleware (optional) -> Handler -> Service -> Middleware (Optional) -> Response + + +## Tech Stack + +### Web Server +- [Axum](https://github.com/tokio-rs/axum) + +### Persistence +- [SQLx](https://github.com/launchbadge/sqlx) - Queries and Migrations +- PostgreSQL + +### AuthN +- OAuth 2 (Google) + +### AuthZ +- JWT + +### Storage +- Object storage \ No newline at end of file diff --git a/backend/api.json b/backend/api.json new file mode 100644 index 000000000..9cb1f9d16 --- /dev/null +++ b/backend/api.json @@ -0,0 +1,2932 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Chaos API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://chaos.csesoc.app/api", + "description": "Production server" + }, + { + "url": "http://localhost:3000/api", + "description": "Local server" + } + ], + "paths": { + "/": { + "get": { + "operationId": "getRoot", + "description": "Root of API", + "tags": ["Miscellaneous"], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "Join DevSoc! https://devsoc.app/" + } + } + } + } + } + } + }, + "/auth/callback/google": { + "get": { + "operationId": "googleCallback", + "description": "Google OAuth callback", + "tags": ["Auth"], + "parameters": [ + { + "name": "code", + "in": "query", + "description": "Google OAuth code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "content": null + } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "logout", + "description": "Invalidates current token", + "tags": ["Auth"], + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully logged out" + } + } + } + } + } + }, + "401": { + "description": "Not logged in", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotLoggedIn" + } + } + } + } + } + } + }, + "/user": { + "get": { + "operationId": "getLoggedInUser", + "description": "Returns info about currently logged in user", + "tags": ["User"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/name": { + "patch": { + "operationId": "updateUserName", + "description": "Updates currently logged in user's name", + "tags": ["User"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "Clancy Tiger" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated name" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/pronouns": { + "patch": { + "operationId": "updateUserPronouns", + "description": "Updates currently logged in user's pronouns", + "tags": ["User"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "pronouns": { + "type": "string", + "example": "They/Them" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated pronouns" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/gender": { + "patch": { + "operationId": "updateUserGender", + "description": "Updates currently logged in user's gender", + "tags": ["User"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "gender": { + "type": "string", + "example": "Female" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated gender" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/zid": { + "patch": { + "operationId": "updateUserZid", + "description": "Updates currently logged in user's zID", + "tags": ["User"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "zid": { + "type": "string", + "example": "z5123456" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated zID" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/degree": { + "patch": { + "operationId": "updateUserDegree", + "description": "Updates currently logged in user's degree", + "tags": ["User"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "degree_name": { + "type": "string", + "example": "Electrical Engineering" + }, + "degree_starting_year": { + "type": "integer", + "example": 2024 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated email" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/user/applications": { + "get": { + "operationId": "getUserApplications", + "description": "Returns info about applications made by currently logged in user", + "tags": ["User"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationDetails" + } + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/organisation": { + "post": { + "operationId": "createOrganisation", + "description": "Create a new organisation", + "tags": ["Organisation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "slug": { + "type": "string", + "description": "ASCII string for URL like https://chaos.csesoc.app/s/unsw-devsoc", + "example": "unsw-devsoc" + }, + "name": { + "type": "string", + "example": "UNSW Software Development Society" + }, + "admin": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500, + "description": "User ID of admin" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully created organisation" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "403": { + "description": "User is not a SuperUser", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Unauthorized" + } + } + } + } + } + } + }, + "/organisation/slug_check": { + "post": { + "operationId": "checkOrganisationSlugAvailability", + "description": "Check if slug is available", + "tags": ["Organisation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "slug": { + "type": "string", + "example": "unsw-devsoc" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Organisation slug is available" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "400": { + "description": "Bad request - slug is in use or not ASCII", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequest" + } + } + } + } + } + } + }, + "/organisation/{id}": { + "get": { + "operationId": "getOrganisationById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns info about specified organisation", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganisationDetails" + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + }, + "delete": { + "operationId": "deleteOrganisationById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Deletes specified organisation", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted organisation" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "403": { + "description": "User is not a SuperUser", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Unauthorized" + } + } + } + } + } + } + }, + "/organisation/slug/{slug}": { + "get": { + "operationId": "getOrganisationBySlug", + "parameters": [ + { + "name": "slug", + "in": "path", + "description": "Organisation slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Returns info about specified organisation", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganisationDetails" + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/organisation/{id}/campaign": { + "post": { + "operationId": "createCampaign", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Create a new campaign inside specified organisation", + "tags": ["Organisation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewCampaign" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully created campaign" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "401": { + "$ref": "#/components/responses/NotOrganisationAdmin" + } + } + } + }, + "/organisation/{id}/campaign/slug_check": { + "post": { + "operationId": "checkCampaignSlugAvailability", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Checks availability of campaign slug in specified organisation", + "tags": ["Organisation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "slug": { + "type": "string", + "example": "2024-subcom-recruitment" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Campaign slug is available" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "401": { + "$ref": "#/components/responses/NotOrganisationAdmin" + } + } + } + }, + "/organisation/{id}/campaigns": { + "get": { + "operationId": "getAllOrganisationCampaigns", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns all (active & ended) campaigns for specified organisation. However, ended campaigns cannot have new applications", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganisationCampaign" + } + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + } + } + } + }, + "/organisation/{id}/email_template": { + "post": { + "operationId": "createEmailTemplate", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Create a new email template within the organisation", + "tags": ["Organisation"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewEmailTemplate" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully created email template" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "401": { + "$ref": "#/components/responses/NotOrganisationAdmin" + } + } + } + }, + "/organisation/{id}/email_templates": { + "get": { + "operationId": "getAllOrganisationEmailTemplates", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Get all email templates for specified organisation", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailTemplate" + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "401": { + "$ref": "#/components/responses/NotOrganisationAdmin" + } + } + } + }, + "/organisation/{id}/logo": { + "patch": { + "operationId": "updateOrganisationLogoById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Update logo for specified organistion. Returns a PUT url to upload new image to", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "upload_url": { + "type": "string", + "description": "Presigned S3 url to upload file", + "example": "https://presignedurldemo.s3.eu-west-2.amazonaws.com/6996987893965262849/2d19617b-46fd-4927-9f53-77d69232ba5d" + } + } + } + } + } + }, + "307": { + "$ref": "#/components/responses/NotLoggedIn" + }, + "401": { + "$ref": "#/components/responses/NotOrganisationAdmin" + } + } + } + }, + "/organisation/{id}/member": { + "get": { + "operationId": "getOrganisationMembersById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns list of members of specified organisation", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "name": { + "type": "string", + "example": "Clancy Lion" + }, + "role": { + "type": "string", + "example": "Admin" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an organisation admin or member.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateOrganisationMembersById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "members": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "format": "int64" + }, + "example": [ + 1541815603606036500, 1541815603606036700, + 1541815287306036500 + ] + } + } + } + } + } + }, + "description": "Specifies members for specified organistion.", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated members." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an organisation admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteOrganisationMemberById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "user_id": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "description": "Specifies member for deletion in organistion.", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated members." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an organisation admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/organisation/{id}/admin": { + "get": { + "operationId": "getOrganisationAdminsById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns list of admins of specified organisation.", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "name": { + "type": "string", + "example": "Clancy Lion" + }, + "role": { + "type": "string", + "example": "Admin" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not a SuperUser.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateOrganisationAdminsById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "members": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "format": "int64" + }, + "example": [ + 1541815603606036500, 1541815603606036700, + 1541815287306036500 + ] + } + } + } + } + } + }, + "description": "Specifies Admins for specified organistion.", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated members." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not a SuperUser.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteOrganisationAdminById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organisation ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "user_id": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "description": "Specifies Admin for deletion in organistion.", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted Admin." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not a SuperUser.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/campaign": { + "get": { + "operationId": "getAllCampaigns", + "description": "Returns all active campaigns.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "campaigns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 6996987893965263000 + }, + "organisation_id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036700 + }, + "organisation_name": { + "type": "string", + "example": "UNSW Software Development Society" + }, + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "cover_image": { + "type": "string", + "example": "2d19617b-46fd-4927-9f53-77d69232ba5d" + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?" + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + } + } + } + } + } + } + } + } + } + } + } + }, + "/campaign/{id}": { + "get": { + "operationId": "getCampaignById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns info about specified campaign.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 6996987893965263000 + }, + "organisation_id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036700 + }, + "organisation_name": { + "type": "string", + "example": "UNSW Software Development Society" + }, + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "cover_image": { + "type": "string", + "example": "2d19617b-46fd-4927-9f53-77d69232ba5d" + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?" + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateCampaignById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?" + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + } + } + } + } + } + }, + "description": "Updates details of specified campaign.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated campaign." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an organisation admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteCampaignById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Deletes specified campaign.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted campaign." + } + } + } + } + } + }, + "403": { + "description": "User is not an admin of campaign's organisation.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/campaign/{id}/banner": { + "patch": { + "operationId": "updateCampaignBannerById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Updates banner image for specified campaign.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "upload_url": { + "type": "string", + "description": "Presigned S3 url to upload file.", + "example": "https://presignedurldemo.s3.eu-west-2.amazonaws.com/6996987893965262849/2d19617b-46fd-4927-9f53-77d69232ba5d" + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an organisation admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/campaign/{id}/role": { + "post": { + "operationId": "createRole", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Creates a new role in a campaign.", + "tags": ["Campaign"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "Chief Mouser" + }, + "description": { + "type": "string", + "required": false, + "example": "Larry the cat is dead, now we need someone else to handle the rat issues at 10th Downing st." + }, + "min_available": { + "type": "int32", + "example": 1 + }, + "max_available": { + "type": "int32", + "example": 3 + }, + "finalised": { + "type": "boolean", + "description": "Whether this role has been finalised (e.g. max avaliable number)", + "example": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully created organisation." + } + } + } + } + } + }, + "403": { + "description": "User is not a Campaign Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/campaign/{id}/roles": { + "get": { + "operationId": "getRolesByCampaignId", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns info about all roles in a campaign", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "campaigns": { + "type": "array", + "items": { + "$ref": "#components/schemas/RoleDetails" + } + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not a Campaign Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/campaign/{id}/applications": { + "get": { + "operationId": "getApplicationsById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "campaign ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns info about all Applications in given Campaign.", + "tags": ["Campaign"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "applications": { + "type": "array", + "items": { + "$ref": "#components/schemas/ApplicationDetails" + } + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not a Campaign Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/role/{id}": { + "get": { + "operationId": "getRoleById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Role ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns info about specified role.", + "tags": ["Role"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "Chief Mouser" + }, + "description": { + "type": "string", + "example": "Larry the cat gone missing! now we need someone else to handle the rat issues at 10th Downing st." + }, + "min_available": { + "type": "int32", + "example": 1 + }, + "max_available": { + "type": "int32", + "example": 3 + }, + "finalised": { + "type": "boolean", + "description": "Whether this role has been finalised (e.g. max avaliable number)", + "example": false + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateRoleById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Role ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Update a role given the role id.", + "tags": ["Role"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "Chief Whip" + }, + "description": { + "type": "string", + "required": false, + "example": "Put a bit of stick about!" + }, + "min_available": { + "type": "int32", + "example": 1 + }, + "max_available": { + "type": "int32", + "example": 3 + }, + "finalised": { + "type": "boolean", + "description": "Whether this role has been finalised (e.g. max avaliable number)", + "example": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully update organisation." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not a Campaign Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteRoleById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Role ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Deletes specified role.", + "tags": ["Role"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted role." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an admin of role's Campaign.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/role/{id}/applications": { + "get": { + "operationId": "getApplicationsByRoleID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Role ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns all applications to a specific role", + "tags": ["Role"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "applications": { + "type": "array", + "items": { + "$ref": "#components/schemas/ApplicationDetails" + } + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an Application Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/application/{id}": { + "get": { + "operationId": "getApplicationByID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Application ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "description": "Returns an applications given its ID", + "tags": ["Application"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "application": { + "type": { + "$ref": "#components/schemas/ApplicationDetails" + } + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an Application Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/application/{id}/private": { + "put": { + "operationId": "updateApplicationPrivateStatus", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Application ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": { + "$ref": "#components/schemas/ApplicationStatus" + } + } + } + } + } + } + }, + "description": "Change Private Status of a specific Application", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated Application Private Status." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an Application Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + }, + "/application/{id}/status": { + "put": { + "operationId": "updateApplicationStatus", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Application ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": { + "$ref": "#components/schemas/ApplicationStatus" + } + } + } + } + } + } + }, + "description": "Change Status of a specific Application", + "tags": ["Organisation"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Successfully updated Application Status." + } + } + } + } + } + }, + "401": { + "description": "Not logged in.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Not logged in." + } + } + } + } + } + }, + "403": { + "description": "User is not an Application Admin.", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NotLoggedIn": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Not logged in" + } + } + }, + "Unauthorized": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Unauthorized" + } + } + }, + "BadRequest": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Bad request" + } + } + }, + "Answer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 6996987893965227000 + }, + "question_id": { + "type": "integer", + "format": "int64", + "example": 6996987893965227000 + }, + "answer_type": { + "type": "string", + "enum": [ + "ShortAnswer", + "MultiChoice", + "MultiSelect", + "DropDown", + "Ranking" + ] + }, + "data": { + "oneOf": [ + { + "type": "string", + "example": "I am passionate about events" + }, + { + "type": "integer", + "example": 6996987893965227000 + }, + { + "type": "array", + "format": "int64", + "example": [ + 6996987893965227000, 69969829832652230000, 6996987893965228000 + ] + } + ] + }, + "created_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "updated_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + } + } + }, + "NewAnswer": { + "type": "object", + "properties": { + "question_id": { + "type": "integer", + "format": "int64", + "example": 6996987893965263000 + }, + "answer_type": { + "type": "string", + "enum": [ + "ShortAnswer", + "MultiChoice", + "MultiSelect", + "DropDown", + "Ranking" + ] + }, + "data": { + "oneOf": [ + { + "type": "string", + "example": "I am passionate about events" + }, + { + "type": "integer", + "example": 6996987893965227000 + }, + { + "type": "array", + "format": "int64", + "example": [ + 6996987893965227000, 69969829832652230000, 6996987893965228000 + ] + } + ] + } + } + }, + "ApplicationStatus": { + "type": "string", + "enum": ["Pending", "Rejected", "Successful"] + }, + "OrganisationDetails": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 6996987893965263000 + }, + "slug": { + "type": "string", + "example": "devsoc-unsw" + }, + "name": { + "type": "string", + "example": "UNSW Software Development Society" + }, + "logo": { + "type": "string", + "example": "76718252-2a13-4de2-bc07-f977c75dc52b" + }, + "created_at": { + "type": "string", + "example": "2024-02-10T18:25:43.511Z" + } + } + }, + "OrganisationCampaign": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 6996987893965263000 + }, + "slug": { + "type": "string", + "example": "2024-subcom-recruitment" + }, + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "cover_image": { + "type": "string", + "example": "2d19617b-46fd-4927-9f53-77d69232ba5d" + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?" + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + } + } + }, + "RoleDetails": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 7036987893965263000 + }, + "campaign_id": { + "type": "integer", + "format": "int64", + "example": 1116987453965262800 + }, + "name": { + "type": "string", + "example": "Chief Mouser" + }, + "description": { + "type": "string", + "example": "Larry the cat gone missing! now we need someone else to handle the rat issues at 10th Downing st." + }, + "min_available": { + "type": "int32", + "example": 1 + }, + "max_available": { + "type": "int32", + "example": 3 + }, + "finalised": { + "type": "boolean", + "description": "Whether this role has been finalised (e.g. max avaliable number)", + "example": false + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "email": { + "type": "string", + "example": "me@example.com" + }, + "zid": { + "type": "string", + "example": "z5555555", + "nullable": true + }, + "name": { + "type": "string", + "example": "Clancy Lion" + }, + "pronouns": { + "type": "string", + "example": "He/Him" + }, + "gender": { + "type": "string", + "example": "Male" + }, + "degree_name": { + "type": "string", + "example": "Computer Science", + "nullable": true + }, + "degree_starting_year": { + "type": "integer", + "example": 2024, + "nullable": true + }, + "role": { + "type": "string", + "enum": ["User", "SuperUser"] + } + } + }, + "UserDetails": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "email": { + "type": "string", + "example": "me@example.com" + }, + "zid": { + "type": "string", + "example": "z5555555" + }, + "name": { + "type": "string", + "example": "Clancy Lion" + }, + "pronouns": { + "type": "string", + "example": "They/Them" + }, + "gender": { + "type": "string", + "example": "Male" + }, + "degree_name": { + "type": "string", + "example": "Computer Science" + }, + "degree_starting_year": { + "type": "integer", + "example": 2024 + } + } + }, + "ApplicationDetails": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "campaign_id": { + "type": "integer", + "format": "int64", + "example": 5141815603606036000 + }, + "user": { + "$ref": "#/components/schemas/UserDetails" + }, + "status": { + "$ref": "#/components/schemas/ApplicationStatus" + }, + "private_status": { + "$ref": "#/components/schemas/ApplicationStatus" + }, + "applied_roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationAppliedRoleDetails" + } + } + } + }, + "ApplicationAppliedRoleDetails": { + "type": "object", + "properties": { + "campaign_role_id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "role_name": { + "type": "String", + "example": "Sponsorships" + }, + "preference": { + "type": "integer", + "format": "int32", + "example": 1 + } + } + }, + "NewCampaign": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "slug": { + "type": "string", + "example": "2024-subcom-recruitment" + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?" + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + } + } + }, + "Campaign": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "slug": { + "type": "string", + "example": "2024-subcom-recruitment" + }, + "name": { + "type": "string", + "example": "2024 Subcommittee Recruitment" + }, + "organisation_id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "organisation_name": { + "type": "string", + "example": "UNSW Software Development Society" + }, + "cover_image": { + "type": "string", + "format": "uuid", + "example": "05ebad1e-8be4-40c3-9d36-140cac9a0075", + "nullable": true + }, + "description": { + "type": "string", + "example": "Are you excited to make a difference?", + "nullable": true + }, + "starts_at": { + "type": "string", + "example": "2024-03-15T18:25:43.511Z" + }, + "ends_at": { + "type": "string", + "example": "2024-04-15T18:25:43.511Z" + }, + "created_at": { + "type": "string", + "example": "2024-02-15T18:25:43.511Z" + }, + "updated_at": { + "type": "string", + "example": "2024-02-15T18:25:43.511Z" + } + } + }, + "EmailTemplate": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "organisation_id": { + "type": "integer", + "format": "int64", + "example": 1541815603606036500 + }, + "name": { + "type": "string", + "example": "Success Email" + }, + "template_subject": { + "type": "string", + "example": "[OUTCOME] {{campaign_name}} - {{role_name}}" + }, + "template_body": { + "type": "string", + "example": "Hi {{name}}, you have been successful in your application for {{organisation_name}} {{role_name}}. Regards {{organisation_name}} Exec" + } + } + }, + "NewEmailTemplate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Success Email" + }, + "template_subject": { + "type": "string", + "example": "[OUTCOME] {{campaign_name}} - {{role_name}}" + }, + "template_body": { + "type": "string", + "example": "Hi {{name}}, you have been successful in your application for {{organisation_name}} {{role_name}}. Regards {{organisation_name}} Exec" + } + } + } + }, + "responses": { + "NotLoggedIn": { + "description": "Redirect to login", + "headers": { + "Location": { + "description": "Login url", + "schema": { + "type": "string", + "format": "uri" + } + } + } + }, + "NotOrganisationAdmin": { + "description": "User is not an organisation admin", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + } + } + } +} diff --git a/backend/api.yaml b/backend/api.yaml new file mode 100644 index 000000000..927a508cd --- /dev/null +++ b/backend/api.yaml @@ -0,0 +1,3141 @@ +openapi: "3.0.0" +info: + title: Chaos API + version: 1.0.0 +servers: + - url: https://chaos.csesoc.app/api + description: Production server + - url: http://localhost:3000/api + description: Local server + +paths: + /auth/callback/google: + get: + operationId: googleCallback + description: Google OAuth callback + tags: + - Auth + parameters: + - name: code + in: query + description: Google OAuth code + required: true + schema: + type: string + responses: + "200": + description: Ok + content: + application/json: + schema: + type: string + description: JWT token + example: "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..." + "401": + description: Authentication failed + content: + application/json: + schema: + $ref: "#/components/schemas/NotLoggedIn" + + /auth/logout: + post: + operationId: logout + description: Invalidates current token + tags: + - Auth + responses: + "200": + description: Ok + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully logged out + "401": + description: Not logged in + content: + application/json: + schema: + $ref: "#/components/schemas/NotLoggedIn" + + /user: + get: + operationId: getLoggedInUser + description: Returns info about currently logged in user + tags: + - User + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "401": + description: Not logged in + content: + application/json: + schema: + $ref: "#/components/schemas/NotLoggedIn" + + /user/name: + patch: + operationId: updateUserName + description: Updates currently logged in user's name + tags: + - User + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + example: "Clancy Tiger" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Updated username + "401": + $ref: "#/components/responses/NotLoggedIn" + + /user/pronouns: + patch: + operationId: updateUserPronouns + description: Updates currently logged in user's pronouns + tags: + - User + requestBody: + required: true + content: + application/json: + schema: + properties: + pronouns: + type: string + example: They/Them + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Updated pronouns + "401": + $ref: "#/components/responses/NotLoggedIn" + + /user/gender: + patch: + operationId: updateUserGender + description: Updates currently logged in user's gender + tags: + - User + requestBody: + required: true + content: + application/json: + schema: + properties: + gender: + type: string + example: Female + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Updated gender + "401": + $ref: "#/components/responses/NotLoggedIn" + + /user/zid: + patch: + operationId: updateUserZid + description: Updates currently logged in user's zID + tags: + - User + requestBody: + required: true + content: + application/json: + schema: + properties: + zid: + type: string + example: z5123456 + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Updated zid + "401": + $ref: "#/components/responses/NotLoggedIn" + + /user/degree: + patch: + operationId: updateUserDegree + description: Updates currently logged in user's degree + tags: + - User + requestBody: + required: true + content: + application/json: + schema: + properties: + degree_name: + type: string + example: Electrical Engineering + degree_starting_year: + type: integer + example: 2024 + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Updated degree + "401": + $ref: "#/components/responses/NotLoggedIn" + + /user/applications: + get: + operationId: getUserApplications + description: Returns info about applications made by currently logged in user + tags: + - User + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApplicationDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /organisation: + post: + operationId: createOrganisation + description: Create a new organisation + tags: + - Organisation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewOrganisation" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created organisation + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/slug_check: + post: + operationId: checkOrganisationSlugAvailability + description: Check if slug is available + tags: + - Organisation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SlugCheck" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Organisation slug is available + "400": + description: Bad request - slug is in use or not ASCII + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}: + get: + operationId: getOrganisationById + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OrganisationDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteOrganisationById + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted organisation + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/slug/{slug}: + get: + operationId: getOrganisationBySlug + parameters: + - name: slug + in: path + description: Organisation slug + required: true + schema: + type: string + description: Returns info about specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OrganisationDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/campaign: + post: + operationId: createCampaign + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Create a new campaign inside specified organisation + tags: + - Organisation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewCampaign" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created campaign + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/campaign/slug_check: + post: + operationId: checkCampaignSlugAvailability + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Checks availability of campaign slug in specified organisation + tags: + - Organisation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SlugCheck" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Campaign slug is available + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/campaigns: + get: + operationId: getAllOrganisationCampaigns + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Returns all campaigns for specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/OrganisationCampaign" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/email_template: + post: + operationId: createEmailTemplate + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Create a new email template within the organisation + tags: + - Organisation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewEmailTemplate" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created email template + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/email_templates: + get: + operationId: getAllOrganisationEmailTemplates + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Get all email templates for specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EmailTemplate" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/logo: + patch: + operationId: updateOrganisationLogoById + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Update logo for specified organisation. Returns a PUT url to upload new image to + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + upload_url: + type: string + description: Presigned S3 url to upload file + example: https://presignedurldemo.s3.eu-west-2.amazonaws.com/6996987893965262849/2d19617b-46fd-4927-9f53-77d69232ba5d + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/members: + get: + operationId: getOrganisationMembers + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Returns list of members of specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/MemberList" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + put: + operationId: updateOrganisationMembers + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + properties: + members: + type: array + items: + type: integer + format: int64 + example: [1541815603606036480, 1541815603606036827] + description: Updates the list of members for an organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated members + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/member: + delete: + operationId: deleteOrganisationMember + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + properties: + user_id: + type: integer + format: int64 + description: Removes a member from the organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully removed member + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/admins: + get: + operationId: getOrganisationAdmins + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + description: Returns list of admins of specified organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/MemberList" + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + put: + operationId: updateOrganisationAdmins + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + properties: + members: + type: array + items: + type: integer + format: int64 + example: [1541815603606036480, 1541815603606036827] + description: Updates the list of administrators for an organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated admins + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /organisation/{id}/admin: + delete: + operationId: deleteOrganisationAdmin + parameters: + - name: id + in: path + description: Organisation ID + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + properties: + user_id: + type: integer + format: int64 + description: Removes an administrator from the organisation + tags: + - Organisation + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully removed admin + "401": + $ref: "#/components/responses/NotSuperUser" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /rating/{id}: + get: + operationId: getRatingById + description: Returns info about specified rating + tags: + - Rating + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RatingDetails" + "307": + $ref: "#/components/responses/NotLoggedIn" + "401": + $ref: "#/components/responses/NotOrganisationMember" + delete: + operationId: deleteRatingById + description: Delete specified rating + tags: + - Rating + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted rating + "307": + $ref: "#/components/responses/NotLoggedIn" + "401": + description: User is not original rating creator + content: + application/json: + schema: + properties: + error: + type: string + example: Unauthorized + put: + operationId: updateRatingById + description: Update specified rating + tags: + - Rating + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewRating" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated rating + "307": + $ref: "#/components/responses/NotLoggedIn" + "401": + description: User is not original rating creator + content: + application/json: + schema: + properties: + error: + type: string + example: Unauthorized + /campaign: + get: + operationId: getAllCampaigns + description: Returns all active campaigns. + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + campaigns: + type: array + items: + type: object + properties: + id: + type: integer + format: int64 + example: 6996987893965262849 + organisation_id: + type: integer + format: int64 + example: 1541815603606036827 + organisation_name: + type: string + example: UNSW Software Development Society + name: + type: string + example: 2024 Subcommittee Recruitment + cover_image: + type: string + example: 2d19617b-46fd-4927-9f53-77d69232ba5d + description: + type: string + example: Are you excited to make a difference? + starts_at: + type: string + example: 2024-03-15T18:25:43.511Z + ends_at: + type: string + example: 2024-04-15T18:25:43.511Z + /campaign/{id}: + get: + operationId: getCampaignById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CampaignDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteCampaignById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted campaign + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/slug/{slug}: + get: + operationId: getCampaignBySlug + parameters: + - name: slug + in: path + description: Campaign slug + required: true + schema: + type: string + description: Returns info about specified campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CampaignDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/role: + post: + operationId: createRole + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Create a new role inside specified campaign + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewRole" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created role + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/roles: + get: + operationId: getCampaignRoles + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Returns all roles for specified campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RoleDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/role/{role_id}: + get: + operationId: getRoleById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified role + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RoleDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + patch: + operationId: updateRole + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Updates specified role + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUpdate" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated role + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteRole + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified role + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted role + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application: + post: + operationId: createApplication + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Create a new application inside specified campaign + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewApplication" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created application + "401": + $ref: "#/components/responses/NotLoggedIn" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/applications: + get: + operationId: getCampaignApplications + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Returns all applications for specified campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApplicationDetails" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}: + get: + operationId: getApplicationById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteApplication + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted application + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/rating: + post: + operationId: createRating + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Create a new rating for specified application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewRating" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created rating + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/ratings: + get: + operationId: getApplicationRatings + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Returns all ratings for specified application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationRatings" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/rating/{rating_id}: + get: + operationId: getRatingById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: rating_id + in: path + description: Rating ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified rating + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RatingDetails" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + patch: + operationId: updateRating + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: rating_id + in: path + description: Rating ID + required: true + schema: + type: integer + format: int64 + description: Updates specified rating + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RatingUpdate" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated rating + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteRating + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: rating_id + in: path + description: Rating ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified rating + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted rating + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/offer: + post: + operationId: createOffer + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Create a new offer for specified application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewOffer" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created offer + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/offer/{offer_id}: + get: + operationId: getOfferById + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Returns info about specified offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OfferDetails" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteOffer + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Deletes specified offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted offer + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/offer/{offer_id}/reply: + post: + operationId: replyToOffer + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Reply to specified offer + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OfferReply" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully replied to offer + "401": + $ref: "#/components/responses/NotLoggedIn" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/offer/{offer_id}/preview: + get: + operationId: previewOfferEmail + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Preview the email that will be sent for specified offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + preview: + type: string + example: Hi John, you have been successful in your application for UNSW DevSoc Software Developer. Regards UNSW DevSoc Exec + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/application/{application_id}/offer/{offer_id}/send: + post: + operationId: sendOfferEmail + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Send the offer email for specified offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully sent offer email + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/role/{role_id}/questions: + get: + operationId: getRoleQuestions + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Returns all questions for a specific role in a campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/QuestionDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /role/{role_id}/applications: + get: + operationId: getRoleApplications + parameters: + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Returns all applications for a specific role + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApplicationDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/question: + post: + operationId: createQuestion + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Create a new question for a campaign + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewQuestion" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created question + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/question/{question_id}: + patch: + operationId: updateQuestion + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: question_id + in: path + description: Question ID + required: true + schema: + type: integer + format: int64 + description: Update a question + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/QuestionUpdate" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated question + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteQuestion + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + - name: question_id + in: path + description: Question ID + required: true + schema: + type: integer + format: int64 + description: Delete a question + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted question + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /campaign/{id}/questions/common: + get: + operationId: getCommonQuestions + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Returns all common questions for a campaign + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/QuestionDetails" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/status: + patch: + operationId: updateApplicationStatus + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Update the status of an application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationStatus" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated application status + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/private: + patch: + operationId: updateApplicationPrivateStatus + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Update the private status of an application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationStatus" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated application private status + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/roles: + patch: + operationId: updateApplicationRoles + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Update the roles for an application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: integer + format: int64 + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated application roles + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/submit: + post: + operationId: submitApplication + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Submit an application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully submitted application + "401": + $ref: "#/components/responses/NotLoggedIn" + + /offer/{offer_id}: + get: + operationId: getOffer + parameters: + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Returns information about a specific offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OfferDetails" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteOffer + parameters: + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Delete an offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted offer + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + post: + operationId: replyToOffer + parameters: + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Reply to an offer + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OfferReply" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully replied to offer + "401": + $ref: "#/components/responses/NotLoggedIn" + + /offer/{offer_id}/preview: + get: + operationId: previewOfferEmail + parameters: + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Preview the email that will be sent for an offer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + preview: + type: string + example: Hi John, you have been successful in your application for UNSW DevSoc Software Developer. Regards UNSW DevSoc Exec + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /offer/{offer_id}/send: + post: + operationId: sendOfferEmail + parameters: + - name: offer_id + in: path + description: Offer ID + required: true + schema: + type: integer + format: int64 + description: Send the offer email + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully sent offer email + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/answers/common: + get: + operationId: getCommonAnswers + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Returns all common answers for an application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Answer" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/answer: + post: + operationId: createAnswer + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + description: Create a new answer for an application + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewAnswer" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created answer + "401": + $ref: "#/components/responses/NotLoggedIn" + + /application/{application_id}/answers/role/{role_id}: + get: + operationId: getRoleAnswers + parameters: + - name: application_id + in: path + description: Application ID + required: true + schema: + type: integer + format: int64 + - name: role_id + in: path + description: Role ID + required: true + schema: + type: integer + format: int64 + description: Returns all answers for a specific role in an application + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Answer" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /answer/{answer_id}: + patch: + operationId: updateAnswer + parameters: + - name: answer_id + in: path + description: Answer ID + required: true + schema: + type: integer + format: int64 + description: Update an answer + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewAnswer" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated answer + "401": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteAnswer + parameters: + - name: answer_id + in: path + description: Answer ID + required: true + schema: + type: integer + format: int64 + description: Delete an answer + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted answer + "401": + $ref: "#/components/responses/NotLoggedIn" + + /email_template/{template_id}: + get: + operationId: getEmailTemplate + parameters: + - name: template_id + in: path + description: Email Template ID + required: true + schema: + type: integer + format: int64 + description: Returns information about a specific email template + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/EmailTemplate" + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + patch: + operationId: updateEmailTemplate + parameters: + - name: template_id + in: path + description: Email Template ID + required: true + schema: + type: integer + format: int64 + description: Update an email template + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewEmailTemplate" + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully updated email template + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + delete: + operationId: deleteEmailTemplate + parameters: + - name: template_id + in: path + description: Email Template ID + required: true + schema: + type: integer + format: int64 + description: Delete an email template + tags: + - Campaign + responses: + "200": + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted email template + "401": + $ref: "#/components/responses/NotOrganisationAdmin" + "307": + $ref: "#/components/responses/NotLoggedIn" + +components: + schemas: + NotLoggedIn: + type: object + properties: + message: + type: string + example: Not logged in + + Unauthorized: + type: object + properties: + message: + type: string + example: Unauthorized + + BadRequest: + type: object + properties: + message: + type: string + example: Bad request + + Answer: + type: object + properties: + id: + type: integer + format: int64 + example: 6996987893965227483 + question_id: + type: integer + format: int64 + example: 6996987893965227483 + answer_type: + type: string + enum: + - ShortAnswer + - MultiChoice + - MultiSelect + - DropDown + - Ranking + data: + oneOf: + - type: string + example: I am passionate about events + - type: integer + example: 6996987893965227483 + - type: array + format: int64 + example: + [6996987893965227483, 69969829832652228374, 6996987893965228374] + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + NewAnswer: + type: object + properties: + question_id: + type: integer + format: int64 + example: 6996987893965262849 + answer_type: + type: string + enum: + - ShortAnswer + - MultiChoice + - MultiSelect + - DropDown + - Ranking + data: + oneOf: + - type: string + example: I am passionate about events + - type: integer + example: 6996987893965227483 + - type: array + format: int64 + example: + [6996987893965227483, 69969829832652228374, 6996987893965228374] + + ApplicationStatus: + type: string + enum: + - Pending + - Rejected + - Successful + + OrganisationDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 6996987893965262849 + slug: + type: string + example: devsoc-unsw + name: + type: string + example: UNSW Software Development Society + logo: + type: string + example: "76718252-2a13-4de2-bc07-f977c75dc52b" + created_at: + type: string + example: 2024-02-10T18:25:43.511Z + + OrganisationCampaign: + type: object + properties: + id: + type: integer + format: int64 + example: 6996987893965262849 + slug: + type: string + example: 2024-subcom-recruitment + name: + type: string + example: 2024 Subcommittee Recruitment + cover_image: + type: string + example: 2d19617b-46fd-4927-9f53-77d69232ba5d + description: + type: string + example: Are you excited to make a difference? + starts_at: + type: string + example: 2024-03-15T18:25:43.511Z + ends_at: + type: string + example: 2024-04-15T18:25:43.511Z + + RoleUpdate: + type: object + properties: + name: + type: string + example: Chief Mouser + description: + type: string + required: False + example: Larry the cat is dead, now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + + RoleDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 7036987893965262849 + campaign_id: + type: integer + format: int64 + example: 1116987453965262849 + name: + type: string + example: Chief Mouser + description: + type: string + example: Larry the cat gone missing! now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + + User: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + email: + type: string + example: clancy.lion@unsw.edu.au + pronouns: + type: string + nullable: true + example: he/him + gender: + type: string + nullable: true + example: male + zid: + type: string + nullable: true + example: z1234567 + degree: + type: string + nullable: true + example: Computer Science + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + UserUpdate: + type: object + properties: + name: + type: string + example: Clancy Lion + pronouns: + type: string + nullable: true + example: he/him + gender: + type: string + nullable: true + example: male + zid: + type: string + nullable: true + example: z1234567 + degree: + type: string + nullable: true + example: Computer Science + + ApplicationDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + campaign_id: + type: integer + format: int64 + example: 1541815603606036480 + user_id: + type: integer + format: int64 + example: 1541815603606036480 + status: + $ref: "#/components/schemas/ApplicationStatus" + private_status: + $ref: "#/components/schemas/ApplicationStatus" + applied_roles: + type: array + items: + $ref: "#/components/schemas/ApplicationAppliedRoleDetails" + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + ApplicationAppliedRoleDetails: + type: object + properties: + role_id: + type: integer + format: int64 + example: 1541815603606036480 + role_name: + type: string + example: Chief Mouser + role_description: + type: string + example: Larry the cat is dead, now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + example: false + + NewCampaign: + type: object + properties: + slug: + type: string + example: 2024-subcom-recruitment + name: + type: string + example: 2024 Subcommittee Recruitment + description: + type: string + example: Are you excited to make a difference? + starts_at: + type: string + example: 2024-03-15T18:25:43.511Z + ends_at: + type: string + example: 2024-04-15T18:25:43.511Z + + Campaign: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + slug: + type: string + example: 2024-subcom-recruitment + name: + type: string + example: 2024 Subcommittee Recruitment + organisation_id: + type: integer + format: int64 + example: 1541815603606036480 + organisation_name: + type: string + example: UNSW Software Development Society + cover_image: + type: string + format: uuid + example: 05ebad1e-8be4-40c3-9d36-140cac9a0075 + nullable: true + description: + type: string + example: Are you excited to make a difference? + nullable: true + starts_at: + type: string + example: 2024-03-15T18:25:43.511Z + ends_at: + type: string + example: 2024-04-15T18:25:43.511Z + created_at: + type: string + example: 2024-02-15T18:25:43.511Z + updated_at: + type: string + example: 2024-02-15T18:25:43.511Z + + RatingDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + application_id: + type: integer + format: int64 + example: 1541815603606036480 + user_id: + type: integer + format: int64 + example: 1541815603606036480 + rating: + type: integer + format: int32 + example: 9 + comment: + type: string + nullable: true + example: Good answer to this question + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + NewRating: + type: object + properties: + rating: + type: integer + format: int32 + example: 9 + comment: + type: string + nullable: true + example: Good answer to this question + + EmailTemplate: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + organisation_id: + type: integer + format: int64 + example: 1541815603606036480 + name: + type: string + example: Success Email + template_subject: + type: string + example: "[OUTCOME] {{campaign_name}} - {{role_name}}" + template_body: + type: string + example: "Hi {{name}}, you have been successful in your application for {{organisation_name}} {{role_name}}. Regards {{organisation_name}} Exec" + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + NewEmailTemplate: + type: object + properties: + name: + type: string + example: Success Email + template_subject: + type: string + example: "[OUTCOME] {{campaign_name}} - {{role_name}}" + template_body: + type: string + example: "Hi {{name}}, you have been successful in your application for {{organisation_name}} {{role_name}}. Regards {{organisation_name}} Exec" + + NewOrganisation: + type: object + properties: + slug: + type: string + example: unsw-devsoc + name: + type: string + example: UNSW Software Development Society + admin: + type: integer + format: int64 + example: 1541815603606036480 + + SlugCheck: + type: object + properties: + slug: + type: string + example: unsw-devsoc + + MemberList: + type: object + properties: + members: + type: array + items: + type: integer + format: int64 + + CampaignDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + slug: + type: string + example: 2024-subcom-recruitment + name: + type: string + example: 2024 Subcommittee Recruitment + cover_image: + type: string + example: 2d19617b-46fd-4927-9f53-77d69232ba5d + description: + type: string + example: Are you excited to make a difference? + starts_at: + type: string + example: 2024-03-15T18:25:43.511Z + ends_at: + type: string + example: 2024-04-15T18:25:43.511Z + + NewRole: + type: object + properties: + name: + type: string + example: Chief Mouser + description: + type: string + required: False + example: Larry the cat is dead, now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + + NewApplication: + type: object + properties: + campaign_id: + type: integer + format: int64 + example: 1541815603606036480 + user_id: + type: integer + format: int64 + example: 1541815603606036480 + status: + $ref: "#/components/schemas/ApplicationStatus" + private_status: + $ref: "#/components/schemas/ApplicationStatus" + applied_roles: + type: array + items: + $ref: "#/components/schemas/ApplicationAppliedRoleDetails" + + ApplicationRatings: + type: object + properties: + ratings: + type: array + items: + $ref: "#/components/schemas/RatingDetails" + + RatingUpdate: + type: object + properties: + rating: + type: integer + format: int32 + example: 9 + comment: + type: string + nullable: true + example: Good answer to this question + + OfferDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + organisation_id: + type: integer + format: int64 + example: 1541815603606036480 + name: + type: string + example: Success Email + template_subject: + type: string + example: "[OUTCOME] {{campaign_name}} - {{role_name}}" + template_body: + type: string + example: "Hi {{name}}, you have been successful in your application for {{organisation_name}} {{role_name}}. Regards {{organisation_name}} Exec" + + OfferReply: + type: object + properties: + reply: + type: string + example: I am interested in the role + + OfferStatus: + type: string + enum: + - PENDING + - ACCEPTED + - DECLINED + - EXPIRED + example: PENDING + + NewOffer: + type: object + properties: + email_template_id: + type: integer + format: int64 + example: 1541815603606036480 + role_id: + type: integer + format: int64 + example: 1541815603606036480 + expiry: + type: string + example: 2024-04-15T18:25:43.511Z + + QuestionDetails: + type: object + properties: + id: + type: integer + format: int64 + example: 1541815603606036480 + campaign_id: + type: integer + format: int64 + example: 1541815603606036480 + question_type: + type: string + enum: + - ShortAnswer + - MultiChoice + - MultiSelect + - DropDown + - Ranking + question: + type: string + example: Why do you want to join this role? + required: + type: boolean + example: true + common: + type: boolean + example: false + created_at: + type: string + example: 2024-03-15T18:25:43.511Z + updated_at: + type: string + example: 2024-03-15T18:25:43.511Z + + NewQuestion: + type: object + properties: + question_type: + type: string + enum: + - ShortAnswer + - MultiChoice + - MultiSelect + - DropDown + - Ranking + question: + type: string + example: Why do you want to join this role? + required: + type: boolean + example: true + common: + type: boolean + example: false + + QuestionUpdate: + type: object + properties: + question_type: + type: string + enum: + - ShortAnswer + - MultiChoice + - MultiSelect + - DropDown + - Ranking + question: + type: string + example: Why do you want to join this role? + required: + type: boolean + example: true + common: + type: boolean + example: false + + responses: + NotLoggedIn: + description: Not logged in + content: + application/json: + schema: + $ref: "#/components/schemas/NotLoggedIn" + + NotSuperUser: + description: User is not a super user + content: + application/json: + schema: + $ref: "#/components/schemas/Unauthorized" + + NotOrganisationAdmin: + description: User is not an organisation admin + content: + application/json: + schema: + $ref: "#/components/schemas/Unauthorized" + + NotOrganisationMember: + description: User is not an organisation member + content: + application/json: + schema: + $ref: "#/components/schemas/Unauthorized" diff --git a/backend/seed_data/Cargo.toml b/backend/database-seeding/Cargo.toml similarity index 50% rename from backend/seed_data/Cargo.toml rename to backend/database-seeding/Cargo.toml index 1daa00552..585df15a1 100644 --- a/backend/seed_data/Cargo.toml +++ b/backend/database-seeding/Cargo.toml @@ -1,12 +1,14 @@ [package] -name = "seed_data" +name = "database-seeding" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -server = { path="../server" } -diesel = "1.4.8" -chrono = "0.4.23" -dotenv = "0.15.0" +server = { path = "../server" } +tokio = { version = "1.34"} +chrono = { version = "0.4", features = ["serde"] } + + +dotenvy = "0.15" \ No newline at end of file diff --git a/backend/database-seeding/src/main.rs b/backend/database-seeding/src/main.rs new file mode 100644 index 000000000..97280dfbe --- /dev/null +++ b/backend/database-seeding/src/main.rs @@ -0,0 +1,11 @@ +use crate::seeder::*; +pub mod seeder; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().expect("Failed to load .env"); + + let seeder = init().await; + + seed_database(seeder).await; +} diff --git a/backend/database-seeding/src/seeder.rs b/backend/database-seeding/src/seeder.rs new file mode 100644 index 000000000..61ccb7fc9 --- /dev/null +++ b/backend/database-seeding/src/seeder.rs @@ -0,0 +1,310 @@ +use std::fmt::Error; +use server::handler::application; +use server::models::app; +use server::models::app::init_app_state; +use server::models::app::AppState; +use server::models::offer::Offer; +use server::models::rating::{Rating, NewRating}; +use server::models::user::{User, UserRole}; +use server::models::organisation::{Organisation}; +use server::models::role::{Role, RoleUpdate}; +use server::models::question::*; +use server::models::answer::*; +use server::models::offer::*; +use server::models::email_template::*; +use server::models::application::{Application, NewApplication, ApplicationRole}; +use chrono::{DateTime, Utc}; + + +/// Struct which hold AppState that contains database connection +pub struct Seeder { + pub app_state: AppState +} + +pub async fn init() -> Seeder { + let seeder = Seeder { + app_state: init_app_state().await + }; + + seeder + } + +pub async fn seed_database(mut seeder: Seeder) { + let mut tx = seeder.app_state.db.begin().await.expect("Error beginning DB transaction"); + + // Super User + let users = vec![ + User { + id: 1, + email: "example.superuser@chaos.devsoc.app".to_string(), + zid: Some("z5555555".to_string()), + name: "Francis Urquhart".to_string(), + pronouns: Some("Ze/Za".to_string()), + gender: Some("Otter".to_string()), + degree_name: Some("Bachelor of Arts".to_string()), + degree_starting_year: Some(1900), + role: UserRole::SuperUser, + }, + User { + id: 2, + email: "example.admin@chaos.devsoc.app".to_string(), + zid: Some("z5555556".to_string()), + name: "Edmund Blackadder".to_string(), + pronouns: Some("De/Da".to_string()), + gender: Some("Kuma".to_string()), + degree_name: Some("Bachelor of Engineering (Honours) (Mining)".to_string()), + degree_starting_year: Some(1914), + role: UserRole::User, + }, + User { + id: 3, + email: "example.user@chaos.devsoc.app".to_string(), + zid: Some("z5555557".to_string()), + name: "John Bull".to_string(), + pronouns: None, + gender: None, + degree_name: Some("Bachelor of Social Work (Honours)".to_string()), + degree_starting_year: Some(2024), + role: UserRole::User, + } + ]; + + for user in users { + User::create_user(user, &mut tx).await.expect("Failed seeding Root User"); + } + + let org_id = Organisation::create(1, + "devsoc".to_string(), + "UNSW DevSoc".to_string(), + &mut seeder.app_state.snowflake_generator, + &mut tx).await.expect("Failed seeding Organisation"); + + Organisation::update_admins(org_id, + vec![2], + &mut tx).await.expect("Failed updating Organisation Admin"); + + let campaign_id = Organisation::create_campaign( + org_id, + "ChaosCampusRecruitment".to_string(), + "Chaos Campus Recruitment".to_string(), + Some("This Campaign will MAKE EVERYONE EMPLOYEED".to_string()), + DateTime::::from_naive_utc_and_offset( + chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().and_hms_milli_opt(0, 0, 0, 0).unwrap(), + Utc, + ) + , + DateTime::::from_naive_utc_and_offset( + chrono::NaiveDate::from_ymd_opt(2040, 1, 1).unwrap().and_hms_milli_opt(0, 0, 0, 0).unwrap(), + Utc, + ), + &mut tx, + &mut seeder.app_state.snowflake_generator, + ) + .await.expect("Failed seeding Campaign"); + + + let role_id_1 = Role::create( + campaign_id, + RoleUpdate { + name: "Software Engineer".to_string(), + description: Some("We are looking for a Software Engineer to join our team.".to_string()), + min_available: 1, + max_avaliable: 1, + finalised: false, + }, + &mut tx, + &mut seeder.app_state.snowflake_generator, + ) + .await.expect("Failed seeding Role 1"); + + let role_id_2 = Role::create( + campaign_id, + RoleUpdate { + name: "High Temperature Starch Heat Treatment Technician".to_string(), + description: Some("Just put the fries in the ...".to_string()), + min_available: 1, + max_avaliable: 100, + finalised: true, + }, + &mut tx, + &mut seeder.app_state.snowflake_generator, + ) + .await.expect("Failed seeding Role 2"); + + let question_id_1 = Question::create( + campaign_id, + "Career History".to_string(), + Some("How many years of industry experience do you have?".to_string()), + true, + None, + true, + QuestionData::DropDown( + MultiOptionData { + options: vec![ + MultiOptionQuestionOption { + id: 0, + text: "Less than 1 year".to_string(), + display_order: 1, + }, + MultiOptionQuestionOption { + id: 0, + text: "2 years".to_string(), + display_order: 2, + }, + MultiOptionQuestionOption { + id: 0, + text: "More than 2 years".to_string(), + display_order: 3, + }, + ] + }, + ), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Question 1"); + + + let question_id_2 = Question::create( + campaign_id, + "Technical Question (pls don't use AI)".to_string(), + Some("What is a Monad?".to_string()), + false, + Some(vec![role_id_1]), + true, + QuestionData::ShortAnswer, + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Question 1"); + + let application_id_1 = Application::create( + campaign_id, + 3, + NewApplication { + applied_roles: vec![ + ApplicationRole { + id: 0, + application_id: 0, + campaign_role_id: role_id_1, + preference: 1, + } + ] + }, + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Application 1"); + + let application_id_2 = Application::create( + campaign_id, + 3, + NewApplication { + applied_roles: vec![ + ApplicationRole { + id: 0, + application_id: 0, + campaign_role_id: role_id_2, + preference: 1, + } + ] + }, + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Application 1"); + + let qtn_1_data = Question::get(question_id_1, &mut tx).await.expect("Failed getting Question 1"); + + let qtn_1_options = + match qtn_1_data.question_data { + QuestionData::DropDown(options) => options.options, + _ => panic!("Question 1 is not a DropDown question"), + }; + + Answer::create( + application_id_1, + question_id_1, + AnswerData::DropDown(qtn_1_options[0].id), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Answer 1"); + + Answer::create( + application_id_2, + question_id_1, + AnswerData::DropDown(qtn_1_options[0].id), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Answer 2"); + + Answer::create( + application_id_1, + question_id_2, + AnswerData::ShortAnswer("A Moand is a Monoid in the Category of Endofunctors, what else do you want?".to_string()), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Answer 3"); + + Rating::create( + NewRating { rating: 69, comment: Some("This guy does not know what they are talking about!".to_string()) }, + application_id_1, + 2, + &mut seeder.app_state.snowflake_generator, + &mut tx) + .await.expect("Failed seeding Rating 1"); + + Rating::create( + NewRating { rating: 100, comment: None }, + application_id_2, + 2, + &mut seeder.app_state.snowflake_generator, + &mut tx) + .await.expect("Failed seeding Rating 2"); + + Rating::create( + NewRating { rating: 100, comment: Some("My cousin's restaurant could use a janitor".to_string()) }, + application_id_2, + 1, + &mut seeder.app_state.snowflake_generator, + &mut tx) + .await.expect("Failed seeding Rating 3"); + + let template = + "Hello {{name}}, + + Congratulations! You have been selected for the role of {{role}} at {{organisation_name}} for our {{campaign_name}}. + + Please confirm your acceptance by {{expiry_date}}. + + Best regards, + The {{organisation_name}} Team".to_string(); + + + let email_template_id = Organisation::create_email_template( + org_id, + "Offer".to_string(), + "[DevSoc] Position Offer".to_string(), + template, + &mut tx, + &mut seeder.app_state.snowflake_generator) + .await.expect("Failed seeding Email Template"); + + Offer::create( + campaign_id, + application_id_1, + email_template_id, + role_id_1, + DateTime::::from_naive_utc_and_offset( + chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().and_hms_milli_opt(0, 0, 0, 0).unwrap(), + Utc, + ), + &mut tx, + &mut seeder.app_state.snowflake_generator) + .await.expect("Failed seeding Offer"); + + tx.commit().await.expect("Failed to commit DB transaction"); +} diff --git a/backend/migrations/00000000000000_diesel_initial_setup/down.sql b/backend/migrations/00000000000000_diesel_initial_setup/down.sql deleted file mode 100644 index a9f526091..000000000 --- a/backend/migrations/00000000000000_diesel_initial_setup/down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - -DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); -DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/backend/migrations/00000000000000_diesel_initial_setup/up.sql b/backend/migrations/00000000000000_diesel_initial_setup/up.sql deleted file mode 100644 index d68895b1a..000000000 --- a/backend/migrations/00000000000000_diesel_initial_setup/up.sql +++ /dev/null @@ -1,36 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - - - - --- Sets up a trigger for the given table to automatically set a column called --- `updated_at` whenever the row is modified (unless `updated_at` was included --- in the modified columns) --- --- # Example --- --- ```sql --- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); --- --- SELECT diesel_manage_updated_at('users'); --- ``` -CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ -BEGIN - EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s - FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD AND - NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at - ) THEN - NEW.updated_at := current_timestamp; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/backend/migrations/2021-12-07-112918_create_users/down.sql b/backend/migrations/2021-12-07-112918_create_users/down.sql deleted file mode 100644 index cc1f647d2..000000000 --- a/backend/migrations/2021-12-07-112918_create_users/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE users; diff --git a/backend/migrations/2021-12-07-112918_create_users/up.sql b/backend/migrations/2021-12-07-112918_create_users/up.sql deleted file mode 100644 index ebd7ab8f9..000000000 --- a/backend/migrations/2021-12-07-112918_create_users/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL, - zid TEXT NOT NULL, - display_name TEXT NOT NULL, - degree_name TEXT NOT NULL, - degree_starting_year INTEGER NOT NULL, - superuser BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('users'); diff --git a/backend/migrations/2021-12-07-114233_create_organisations/down.sql b/backend/migrations/2021-12-07-114233_create_organisations/down.sql deleted file mode 100644 index c0f04cf40..000000000 --- a/backend/migrations/2021-12-07-114233_create_organisations/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE organisations; diff --git a/backend/migrations/2021-12-07-114233_create_organisations/up.sql b/backend/migrations/2021-12-07-114233_create_organisations/up.sql deleted file mode 100644 index a608b61ff..000000000 --- a/backend/migrations/2021-12-07-114233_create_organisations/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE organisations ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - logo BYTEA, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('organisations'); diff --git a/backend/migrations/2021-12-07-114404_create_organisation_users/down.sql b/backend/migrations/2021-12-07-114404_create_organisation_users/down.sql deleted file mode 100644 index 3677372d8..000000000 --- a/backend/migrations/2021-12-07-114404_create_organisation_users/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE organisation_users; -DROP TYPE admin_level; diff --git a/backend/migrations/2021-12-07-114404_create_organisation_users/up.sql b/backend/migrations/2021-12-07-114404_create_organisation_users/up.sql deleted file mode 100644 index 25f191ff4..000000000 --- a/backend/migrations/2021-12-07-114404_create_organisation_users/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TYPE admin_level AS ENUM ('ReadOnly', 'Director', 'Admin'); - -CREATE TABLE organisation_users ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - organisation_id INTEGER NOT NULL REFERENCES organisations (id), - admin_level admin_level NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('organisation_users'); diff --git a/backend/migrations/2021-12-07-114733_campaigns/down.sql b/backend/migrations/2021-12-07-114733_campaigns/down.sql deleted file mode 100644 index 4f386f056..000000000 --- a/backend/migrations/2021-12-07-114733_campaigns/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE campaigns; diff --git a/backend/migrations/2021-12-07-114733_campaigns/up.sql b/backend/migrations/2021-12-07-114733_campaigns/up.sql deleted file mode 100644 index 1cb6f3150..000000000 --- a/backend/migrations/2021-12-07-114733_campaigns/up.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE campaigns ( - id SERIAL PRIMARY KEY, - organisation_id INTEGER NOT NULL REFERENCES organisations (id), - name TEXT NOT NULL, - cover_image BYTEA, - description TEXT NOT NULL, - starts_at TIMESTAMP NOT NULL, - ends_at TIMESTAMP NOT NULL, - published BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('campaigns'); diff --git a/backend/migrations/2021-12-07-114906_roles/down.sql b/backend/migrations/2021-12-07-114906_roles/down.sql deleted file mode 100644 index 0f9306dc2..000000000 --- a/backend/migrations/2021-12-07-114906_roles/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE roles; diff --git a/backend/migrations/2021-12-07-114906_roles/up.sql b/backend/migrations/2021-12-07-114906_roles/up.sql deleted file mode 100644 index 4959761ca..000000000 --- a/backend/migrations/2021-12-07-114906_roles/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE roles ( - id SERIAL PRIMARY KEY, - campaign_id INTEGER NOT NULL REFERENCES campaigns (id), - name TEXT NOT NULL, - description TEXT, - min_available INTEGER NOT NULL, - max_available INTEGER NOT NULL, - finalised BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('roles'); diff --git a/backend/migrations/2021-12-07-115409_questions/down.sql b/backend/migrations/2021-12-07-115409_questions/down.sql deleted file mode 100644 index 17135d605..000000000 --- a/backend/migrations/2021-12-07-115409_questions/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE questions; diff --git a/backend/migrations/2021-12-07-115409_questions/up.sql b/backend/migrations/2021-12-07-115409_questions/up.sql deleted file mode 100644 index 19cd4c396..000000000 --- a/backend/migrations/2021-12-07-115409_questions/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE questions ( - id SERIAL PRIMARY KEY, - role_id INTEGER NOT NULL REFERENCES roles (id), - title TEXT NOT NULL, - description TEXT, - max_bytes INTEGER NOT NULL, - required BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('questions'); diff --git a/backend/migrations/2021-12-07-115608_applications/down.sql b/backend/migrations/2021-12-07-115608_applications/down.sql deleted file mode 100644 index 0739143e4..000000000 --- a/backend/migrations/2021-12-07-115608_applications/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE applications; -DROP TYPE application_status; diff --git a/backend/migrations/2021-12-07-115608_applications/up.sql b/backend/migrations/2021-12-07-115608_applications/up.sql deleted file mode 100644 index 49d6292ba..000000000 --- a/backend/migrations/2021-12-07-115608_applications/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TYPE application_status AS ENUM ('Pending', 'Rejected', 'Success'); - -CREATE TABLE applications ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - role_id INTEGER NOT NULL REFERENCES roles (id), - status application_status NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('applications'); diff --git a/backend/migrations/2021-12-07-115832_answers/down.sql b/backend/migrations/2021-12-07-115832_answers/down.sql deleted file mode 100644 index 90ff272a5..000000000 --- a/backend/migrations/2021-12-07-115832_answers/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE answers; diff --git a/backend/migrations/2021-12-07-115832_answers/up.sql b/backend/migrations/2021-12-07-115832_answers/up.sql deleted file mode 100644 index b3f502136..000000000 --- a/backend/migrations/2021-12-07-115832_answers/up.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE answers ( - id SERIAL PRIMARY KEY, - application_id INTEGER NOT NULL REFERENCES applications (id), - question_id INTEGER NOT NULL REFERENCES questions (id), - description TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('answers'); diff --git a/backend/migrations/2021-12-07-115931_comments/down.sql b/backend/migrations/2021-12-07-115931_comments/down.sql deleted file mode 100644 index 9ef7d1265..000000000 --- a/backend/migrations/2021-12-07-115931_comments/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE comments; diff --git a/backend/migrations/2021-12-07-115931_comments/up.sql b/backend/migrations/2021-12-07-115931_comments/up.sql deleted file mode 100644 index c286ce298..000000000 --- a/backend/migrations/2021-12-07-115931_comments/up.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE comments ( - id SERIAL PRIMARY KEY, - application_id INTEGER NOT NULL REFERENCES applications (id), - commenter_user_id INTEGER NOT NULL REFERENCES users (id), - description TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('comments'); diff --git a/backend/migrations/2021-12-07-120033_ratings/down.sql b/backend/migrations/2021-12-07-120033_ratings/down.sql deleted file mode 100644 index c1ae6185b..000000000 --- a/backend/migrations/2021-12-07-120033_ratings/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE ratings; diff --git a/backend/migrations/2021-12-07-120033_ratings/up.sql b/backend/migrations/2021-12-07-120033_ratings/up.sql deleted file mode 100644 index 432da2ff9..000000000 --- a/backend/migrations/2021-12-07-120033_ratings/up.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE ratings ( - id SERIAL PRIMARY KEY, - application_id INTEGER NOT NULL REFERENCES applications (id), - rater_user_id INTEGER NOT NULL REFERENCES users (id), - rating INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -SELECT diesel_manage_updated_at('ratings'); diff --git a/backend/migrations/2022-05-30-145617_questions/down.sql b/backend/migrations/2022-05-30-145617_questions/down.sql deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/migrations/2022-05-30-145617_questions/up.sql b/backend/migrations/2022-05-30-145617_questions/up.sql deleted file mode 100644 index c835b42de..000000000 --- a/backend/migrations/2022-05-30-145617_questions/up.sql +++ /dev/null @@ -1,20 +0,0 @@ -ALTER TABLE IF EXISTS questions - DROP CONSTRAINT questions_role_id_fkey; - -ALTER TABLE IF EXISTS questions - ALTER COLUMN role_id TYPE INTEGER[] - USING array[role_id]::INTEGER[]; - -ALTER TABLE IF EXISTS questions - RENAME COLUMN role_id TO role_ids; - -CREATE TABLE IF NOT EXISTS questions ( - id SERIAL PRIMARY KEY, - role_ids INTEGER[] NOT NULL, - title TEXT NOT NULL, - description TEXT, - max_bytes INTEGER NOT NULL, - required BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/backend/migrations/2022-11-21-043159_application_private_statuses/down.sql b/backend/migrations/2022-11-21-043159_application_private_statuses/down.sql deleted file mode 100644 index 912ef129b..000000000 --- a/backend/migrations/2022-11-21-043159_application_private_statuses/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE applications -DROP COLUMN private_status; diff --git a/backend/migrations/2022-11-21-043159_application_private_statuses/up.sql b/backend/migrations/2022-11-21-043159_application_private_statuses/up.sql deleted file mode 100644 index 9b9efd676..000000000 --- a/backend/migrations/2022-11-21-043159_application_private_statuses/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE applications -ADD COLUMN private_status application_status; - -UPDATE applications -SET private_status = status; - -ALTER TABLE applications -ALTER COLUMN status -SET NOT NULL; diff --git a/backend/migrations/2022-12-04-065103_image_files/down.sql b/backend/migrations/2022-12-04-065103_image_files/down.sql deleted file mode 100644 index 1ba72d005..000000000 --- a/backend/migrations/2022-12-04-065103_image_files/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE organisations -ALTER COLUMN logo TYPE BYTEA USING NULL; - -ALTER TABLE campaigns -ALTER COLUMN cover_image TYPE BYTEA USING NULL; diff --git a/backend/migrations/2022-12-04-065103_image_files/up.sql b/backend/migrations/2022-12-04-065103_image_files/up.sql deleted file mode 100644 index ecafde660..000000000 --- a/backend/migrations/2022-12-04-065103_image_files/up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE organisations -ALTER COLUMN logo TYPE TEXT USING NULL; - -ALTER TABLE campaigns -ALTER COLUMN cover_image TYPE TEXT USING NULL; diff --git a/backend/migrations/2023-06-07-042751_user_gender/down.sql b/backend/migrations/2023-06-07-042751_user_gender/down.sql deleted file mode 100644 index 0c5e3ef5a..000000000 --- a/backend/migrations/2023-06-07-042751_user_gender/down.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE users -DROP COLUMN gender; - -DROP TYPE user_gender; diff --git a/backend/migrations/2023-06-07-042751_user_gender/up.sql b/backend/migrations/2023-06-07-042751_user_gender/up.sql deleted file mode 100644 index d6007cd33..000000000 --- a/backend/migrations/2023-06-07-042751_user_gender/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TYPE user_gender AS ENUM ('Female', 'Male', 'Unspecified'); - -ALTER TABLE users - ADD COLUMN gender user_gender DEFAULT 'Unspecified' NOT NULL; diff --git a/backend/migrations/2023-09-20-052638_user_pronouns/down.sql b/backend/migrations/2023-09-20-052638_user_pronouns/down.sql deleted file mode 100644 index 6cc4cb645..000000000 --- a/backend/migrations/2023-09-20-052638_user_pronouns/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users -DROP COLUMN pronouns; diff --git a/backend/migrations/2023-09-20-052638_user_pronouns/up.sql b/backend/migrations/2023-09-20-052638_user_pronouns/up.sql deleted file mode 100644 index ef54ae9c3..000000000 --- a/backend/migrations/2023-09-20-052638_user_pronouns/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users - ADD COLUMN pronouns TEXT DEFAULT '' NOT NULL; diff --git a/backend/migrations/20240406023149_create_users.sql b/backend/migrations/20240406023149_create_users.sql new file mode 100644 index 000000000..46ec789ee --- /dev/null +++ b/backend/migrations/20240406023149_create_users.sql @@ -0,0 +1,21 @@ +CREATE TYPE user_role AS ENUM ('User', 'SuperUser'); + +CREATE TABLE users ( + id BIGINT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + zid TEXT, + name TEXT NOT NULL, + pronouns TEXT, + gender TEXT, + degree_name TEXT, + degree_starting_year INTEGER, + role user_role NOT NULL DEFAULT 'User', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Seed initial superuser +INSERT INTO users (id, email, name, role) +VALUES (10000, 'chaosdirectors@devsoc.app', 'Super Admin', 'SuperUser'); + +CREATE UNIQUE INDEX IDX_users_email_lower on users((lower(email))); \ No newline at end of file diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql new file mode 100644 index 000000000..2c3bfdf79 --- /dev/null +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -0,0 +1,25 @@ +CREATE TABLE organisations ( + id BIGINT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + logo UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TYPE organisation_role AS ENUM ('User', 'Admin'); + +CREATE TABLE organisation_members ( + id BIGSERIAL PRIMARY KEY, + organisation_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role organisation_role NOT NULL DEFAULT 'User', + CONSTRAINT FK_organisation_members_organisation + FOREIGN KEY(organisation_id) + REFERENCES organisations(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + +CREATE INDEX IDX_organisation_admins_organisation on organisation_members(organisation_id); diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql new file mode 100644 index 000000000..b1d8b6ecb --- /dev/null +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -0,0 +1,37 @@ +CREATE TABLE campaigns ( + id BIGINT PRIMARY KEY, + organisation_id BIGINT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + cover_image UUID, + description TEXT, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_campaigns_organisations + FOREIGN KEY(organisation_id) + REFERENCES organisations(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (organisation_id, slug) +); + +CREATE TABLE campaign_roles ( + id BIGINT PRIMARY KEY, + campaign_id BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT, + min_available INTEGER NOT NULL, + max_available INTEGER NOT NULL, + finalised BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_campaign_roles_campaign + FOREIGN KEY(campaign_id) + REFERENCES campaigns(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IDX_campaign_roles_campaign on campaign_roles(campaign_id); \ No newline at end of file diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql new file mode 100644 index 000000000..ec44dea0e --- /dev/null +++ b/backend/migrations/20240406031400_create_questions.sql @@ -0,0 +1,56 @@ +CREATE TYPE question_type AS ENUM ('ShortAnswer', 'MultiChoice', 'MultiSelect', 'DropDown', 'Ranking'); + +CREATE TABLE questions ( + id BIGINT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + common BOOLEAN NOT NULL, + required BOOLEAN NOT NULL, + question_type question_type NOT NULL, + campaign_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_questions_campaigns + FOREIGN KEY(campaign_id) + REFERENCES campaigns(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE multi_option_question_options ( + id BIGINT PRIMARY KEY, + text TEXT NOT NULL, + question_id BIGINT NOT NULL, + display_order INTEGER NOT NULL, + CONSTRAINT FK_multi_option_question_options_questions + FOREIGN KEY(question_id) + REFERENCES questions(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (question_id, display_order) +); + +CREATE INDEX IDX_multi_option_question_options_questions on multi_option_question_options(question_id); + +CREATE TABLE question_roles ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + CONSTRAINT FK_question_roles_questions + FOREIGN KEY(question_id) + REFERENCES questions(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT FK_question_roles_roles + FOREIGN KEY(role_id) + REFERENCES campaign_roles(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (question_id, role_id) +); + +CREATE INDEX IDX_question_roles_questions on question_roles(question_id); +CREATE INDEX IDX_question_roles_roles on question_roles(role_id); \ No newline at end of file diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql new file mode 100644 index 000000000..edcae4a38 --- /dev/null +++ b/backend/migrations/20240406031915_create_applications.sql @@ -0,0 +1,138 @@ +CREATE TYPE application_status AS ENUM ('Pending', 'Rejected', 'Successful'); + +CREATE TABLE applications ( + id BIGINT PRIMARY KEY, + campaign_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + status application_status NOT NULL DEFAULT 'Pending', + private_status application_status NOT NULL DEFAULT 'Pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT FK_applications_campaigns + FOREIGN KEY(campaign_id) + REFERENCES campaigns(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_applications_users + FOREIGN KEY(user_id) + REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE application_roles ( + id BIGSERIAL PRIMARY KEY, + application_id BIGINT NOT NULL, + campaign_role_id BIGINT NOT NULL, + preference INTEGER NOT NULL, + CONSTRAINT FK_application_roles_applications + FOREIGN KEY(application_id) + REFERENCES applications(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_application_roles_campaign_roles + FOREIGN KEY(campaign_role_id) + REFERENCES campaign_roles(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IDX_application_roles_applications on application_roles (application_id); +CREATE INDEX IDX_application_roles_campaign_roles on application_roles (campaign_role_id); + +CREATE TABLE answers ( + id BIGINT PRIMARY KEY, + application_id BIGINT NOT NULL, + question_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_answers_applications + FOREIGN KEY(application_id) + REFERENCES applications(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_answers_questions + FOREIGN KEY(question_id) + REFERENCES questions(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED +); + +CREATE INDEX IDX_answers_applications on answers (application_id); +CREATE INDEX IDX_answers_questions on answers (question_id); + +CREATE TABLE short_answer_answers ( + id BIGSERIAL PRIMARY KEY, + text TEXT NOT NULL, + answer_id BIGINT NOT NULL, + CONSTRAINT FK_short_answer_answers_answers + FOREIGN KEY(answer_id) + REFERENCES answers(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IDX_short_answer_answers_answers on short_answer_answers (answer_id); + +CREATE TABLE multi_option_answer_options ( + id BIGSERIAL PRIMARY KEY, + option_id BIGINT NOT NULL, + answer_id BIGINT NOT NULL, + CONSTRAINT FK_multi_option_answer_options_question_options + FOREIGN KEY(option_id) + REFERENCES multi_option_question_options(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT FK_multi_option_answer_options_answers + FOREIGN KEY(answer_id) + REFERENCES answers(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE ranking_answer_rankings ( + id BIGSERIAL PRIMARY KEY, + option_id BIGINT NOT NULL, + rank INTEGER NOT NULL, + answer_id BIGINT NOT NULL, + CONSTRAINT FK_ranking_answer_rankings_question_options + FOREIGN KEY(option_id) + REFERENCES multi_option_question_options(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT FK_ranking_answer_rankings_answers + FOREIGN KEY(answer_id) + REFERENCES answers(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IDX_multi_option_answer_options_question_options on multi_option_answer_options(option_id); +CREATE INDEX IDX_multi_option_answer_options_answers on multi_option_answer_options(answer_id); + +CREATE TABLE application_ratings ( + id BIGINT PRIMARY KEY, + application_id BIGINT NOT NULL, + rater_id BIGINT NOT NULL, + rating INTEGER NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT FK_application_ratings_applications + FOREIGN KEY(application_id) + REFERENCES applications(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_application_ratings_users + FOREIGN KEY(rater_id) + REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IDX_application_ratings_applications on application_ratings(application_id); +CREATE INDEX IDX_application_ratings_users on application_ratings(rater_id); \ No newline at end of file diff --git a/backend/migrations/20241124054711_email_templates.sql b/backend/migrations/20241124054711_email_templates.sql new file mode 100644 index 000000000..ff1a48797 --- /dev/null +++ b/backend/migrations/20241124054711_email_templates.sql @@ -0,0 +1,13 @@ +CREATE TABLE email_templates ( + id BIGINT PRIMARY KEY, + organisation_id BIGINT NOT NULL, + name TEXT NOT NULL, + template_subject TEXT NOT NULL, + template_body TEXT NOT NULL, + CONSTRAINT FK_email_templates_organisations + FOREIGN KEY(organisation_id) + REFERENCES organisations(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (organisation_id, name) +); \ No newline at end of file diff --git a/backend/migrations/20241126113027_offers.sql b/backend/migrations/20241126113027_offers.sql new file mode 100644 index 000000000..afe639255 --- /dev/null +++ b/backend/migrations/20241126113027_offers.sql @@ -0,0 +1,32 @@ +CREATE TYPE offer_status AS ENUM ('Draft', 'Sent', 'Accepted', 'Declined'); + +CREATE TABLE offers ( + id BIGINT PRIMARY KEY, + campaign_id BIGINT NOT NULL, + application_id BIGINT NOT NULL, + email_template_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + status offer_status NOT NULL DEFAULT 'Draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_offers_campaigns + FOREIGN KEY(campaign_id) + REFERENCES campaigns(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_applications + FOREIGN KEY(application_id) + REFERENCES applications(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_email_templates + FOREIGN KEY(email_template_id) + REFERENCES email_templates(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_roles + FOREIGN KEY(role_id) + REFERENCES campaign_roles(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); \ No newline at end of file diff --git a/backend/scripts/become_super_user.sh b/backend/scripts/become_super_user.sh deleted file mode 100755 index 1f15a822f..000000000 --- a/backend/scripts/become_super_user.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -echo "Starting the script" -echo "====================" -echo "what is the email for the account?" -read SUPERUSER_EMAIL - - -echo "make $SUPERUSER_EMAIL a superuser? (y/n)" -read SUPERUSER_ANSWER -if [ "$SUPERUSER_ANSWER" == "n" ]; then - echo "skipping superuser creation" - exit -fi - - - -# expose env variables from .env -if [ -f .env ] -then - export $(cat .env | sed 's/#.*//g' | xargs) -else - echo "no .env file found" - exit -fi - -echo "db url is $DATABASE_URL" - -psql $DATABASE_URL << EOF - - UPDATE users SET superuser = true WHERE email = '$SUPERUSER_EMAIL'; - -EOF diff --git a/backend/scripts/seed.sh b/backend/scripts/seed.sh deleted file mode 100755 index 4054cd63b..000000000 --- a/backend/scripts/seed.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -echo "WARNING - this will WIPE your local database" -echo "WARNING - this will WIPE your local changes to database/schema.rs" -echo "Are you sure you want to continue? (y/n)" -read answer -if [ "$answer" != "y" ]; then - echo "Aborting" - exit 1 -fi - -echo "Deleting images directory" -rm -rf images - -cd server/src && diesel database reset - -cd ../../seed_data && cargo run --bin seed_data - -mv images .. diff --git a/backend/seed_data/assets/180DC.png b/backend/seed_data/assets/180DC.png deleted file mode 100644 index beccfcfc5..000000000 Binary files a/backend/seed_data/assets/180DC.png and /dev/null differ diff --git a/backend/seed_data/assets/csesoc_logo.png b/backend/seed_data/assets/csesoc_logo.png deleted file mode 100644 index 847d441db..000000000 Binary files a/backend/seed_data/assets/csesoc_logo.png and /dev/null differ diff --git a/backend/seed_data/assets/csesoc_peer_mentoring.jpg b/backend/seed_data/assets/csesoc_peer_mentoring.jpg deleted file mode 100644 index e2da054c8..000000000 Binary files a/backend/seed_data/assets/csesoc_peer_mentoring.jpg and /dev/null differ diff --git a/backend/seed_data/src/main.rs b/backend/seed_data/src/main.rs deleted file mode 100644 index feb583400..000000000 --- a/backend/seed_data/src/main.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod seed; - -use dotenv; - -fn main() { - dotenv::dotenv().ok(); - seed::seed(); -} diff --git a/backend/seed_data/src/seed.rs b/backend/seed_data/src/seed.rs deleted file mode 100644 index f542faa2f..000000000 --- a/backend/seed_data/src/seed.rs +++ /dev/null @@ -1,311 +0,0 @@ -#![allow(unused_variables)] - -use backend::database::models::*; -use backend::database::schema::{AdminLevel, ApplicationStatus, UserGender}; -use backend::images::{save_image, try_decode_bytes}; -use chrono::naive::NaiveDate; -use diesel::pg::PgConnection; -use diesel::prelude::*; -use std::env; - -pub fn establish_connection() -> PgConnection { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - PgConnection::establish(&database_url).expect(&format!("Error connecting to {}", database_url)) -} - -pub fn seed() { - println!("SEEDING\n"); - - let connection = establish_connection(); - let users = vec![ - NewUser { - email: "shrey.somaiya@gmail.com".to_string(), - zid: "z5257343".to_string(), - display_name: "Shrey Somaiya".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2019, - gender: UserGender::Unspecified, - pronouns: "they/them".to_string(), - superuser: true, - }, - NewUser { - email: "fake.user@gmail.com".to_string(), - zid: "z1234567".to_string(), - display_name: "Fake User".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2019, - gender: UserGender::Unspecified, - pronouns: "".to_string(), - superuser: false, - }, - NewUser { - email: "michael.gribben@gmail.com".to_string(), - zid: "z5259232".to_string(), - display_name: "Michael Gribben".to_string(), - degree_name: "B. Eng (Software)".to_string(), - degree_starting_year: 2019, - gender: UserGender::Male, - pronouns: "he/him".to_string(), - superuser: false, - }, - NewUser { - email: "giuliana.debellis@gmail.com".to_string(), - zid: "z5259232".to_string(), - display_name: "Giuliana Debellis".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2020, - gender: UserGender::Female, - pronouns: "she/her".to_string(), - superuser: false, - }, - NewUser { - email: "lachlan.ting@gmail.com".to_string(), - zid: "z5264855".to_string(), - display_name: "Lachlan Ting".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2019, - gender: UserGender::Male, - pronouns: "he/him".to_string(), - superuser: false, - }, - NewUser { - email: "hayes.choy@gmail.com".to_string(), - zid: "z528816".to_string(), - display_name: "Hayes Choi".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2020, - gender: UserGender::Male, - pronouns: "he/him".to_string(), - superuser: false, - }, - NewUser { - email: "clarence.feng@gmail.com".to_string(), - zid: "z5260633".to_string(), - display_name: "Clarence Feng".to_string(), - degree_name: "B. CompSci".to_string(), - degree_starting_year: 2020, - gender: UserGender::Male, - pronouns: "he/him".to_string(), - superuser: false, - }, - ]; - - // add all users - for user in &users { - user.insert(&connection) - .expect(&format!("Failed to insert user {}.", user.display_name)); - } - println!("... Added {} users\n", users.len()); - - // create two organisations - let csesoc_org_logo_id = "d6b7b23d-064b-40f2-9b73-9a4cd32ee9c6"; - let degrees_org_logo_id = "adebf7f3-aa1e-4712-b5ca-051430bfaf8e"; - let csesoc_org_logo = try_decode_bytes(std::fs::read("./assets/csesoc_logo.png").unwrap()) - .expect("./assets/csesoc_logo.png missing!"); - let degrees_org_logo = try_decode_bytes(std::fs::read("./assets/180DC.png").unwrap()) - .expect("./assets/180DC.png missing!"); - save_image( - csesoc_org_logo, - backend::images::ImageLocation::ORGANISATIONS, - csesoc_org_logo_id, - ) - .expect("Failed saving CSESoc Logo"); - save_image( - degrees_org_logo, - backend::images::ImageLocation::ORGANISATIONS, - degrees_org_logo_id, - ) - .expect("Failed saving 180DC Logo"); - - let orgs = vec![ - NewOrganisation { - name: "CSESoc UNSW".to_string(), - logo: Some(csesoc_org_logo_id.to_string()), - }, - NewOrganisation { - name: "180 Degrees Consulting".to_string(), - logo: Some(degrees_org_logo_id.to_string()), - }, - ]; - - for org in &orgs { - org.insert(&connection) - .expect(&format!("Failed to insert org {}.", org.name)); - } - - assert!(Organisation::get_all(&connection).len() == 2); - - println!("... Added {} organizations\n", orgs.len()); - // make giuliana the admin of csesoc - - let giuliana_user = User::get_from_email(&connection, "giuliana.debellis@gmail.com") - .expect("Failed to get giuliana user from email"); - - let csesoc_org = - Organisation::find_by_name(&connection, "CSESoc UNSW").expect("csesoc should exist"); - - let giuliana_csesoc_admin = NewOrganisationUser { - user_id: giuliana_user.id, - organisation_id: csesoc_org.id, - admin_level: AdminLevel::Admin, - } - .insert(&connection) - .expect("Failed to insert giuliana as admin"); - - println!("... Adding guiuliana as csesoc admin\n"); - - // make clarence a director of csesoc - let clarence_user = User::get_from_email(&connection, "clarence.feng@gmail.com") - .expect("Failed to get giuliana user from email"); - - let clarence_csesoc_director = NewOrganisationUser { - user_id: clarence_user.id, - organisation_id: csesoc_org.id, - admin_level: AdminLevel::Director, - } - .insert(&connection) - .expect("failed to insert org user clarence"); - - println!("... Adding clarence as csesoc director\n"); - // create peer mentoring campaign for csesoc - - let peer_mentoring_logo_id = "523fde49-027a-4fc8-b296-aaefe9e215d6"; - let peer_mentoring_logo = - try_decode_bytes(std::fs::read("./assets/csesoc_peer_mentoring.jpg").unwrap()) - .expect("./assets/csesoc_peer_mentoring.jpg missing!"); - save_image( - peer_mentoring_logo, - backend::images::ImageLocation::CAMPAIGNS, - peer_mentoring_logo_id, - ) - .expect("Failed saving Peer Mentoring Logo"); - - let new_campaign = NewCampaign { - name: "2022 Peer Mentor Recruitment".to_string(), - description: "Peer mentors are an important part of CSESoc and university life at UNSW. We are looking for enthusiastic students who are passionate about helping first-year students, gaining leadership experience, communication skills, some resume-worthy additions, and having a lot of fun in the upcoming term (Term 1, 2022)! 🎉".to_string(), - organisation_id: csesoc_org.id, - starts_at: NaiveDate::from_ymd_opt(2022, 1, 1).unwrap().and_hms_opt(10, 00, 00).unwrap(), - ends_at: NaiveDate::from_ymd_opt(2022, 2, 20).unwrap().and_hms_opt(23, 59, 59).unwrap(), - cover_image: Some(peer_mentoring_logo_id.to_string()), - published: true, - }.insert(&connection).expect("failed to insert new campaign"); - - println!("... Creating peer mentoring campaign\n"); - - let mentor_role = RoleUpdate { - campaign_id: new_campaign.id, - name: "Peer Mentor".to_string(), - description: Some("help students 5head".to_string()), - min_available: 70, - max_available: 100, - finalised: false, - } - .insert(&connection) - .expect("Failed to insert Peer Mentor role"); - - let senior_mentor_role = RoleUpdate { - campaign_id: new_campaign.id, - name: "Senior Mentor".to_string(), - description: Some("help with organisation".to_string()), - min_available: 1, - max_available: 3, - finalised: false, - } - .insert(&connection) - .expect("Failed to insert senior mentor role"); - - println!("... Creating peer mentor and senior mentor role\n"); - // attatch two questions two senior mentor role - let question_one = NewQuestion { - title: "What is the meaning of life?".to_string(), - max_bytes: 100, - role_ids: vec![senior_mentor_role.id], - required: false, - description: Some("Please ensure to go into great detail!".to_string()), - } - .insert(&connection) - .expect("Failed to insert question"); - - let question_two = NewQuestion { - title: "Why do you want to be a Peer Mentor".to_string(), - max_bytes: 300, - role_ids: vec![senior_mentor_role.id, mentor_role.id], - required: true, - description: Some("Please explain why you would like to be a peer mentor!".to_string()), - } - .insert(&connection) - .expect("Failed to insert question"); - - println!("... Creating senior mentor questions\n"); - // hayes choy wants to apply for the senior peer mentor role - - let application = NewApplication { - role_id: senior_mentor_role.id, - user_id: User::get_from_email(&connection, "hayes.choy@gmail.com") - .unwrap() - .id, - status: ApplicationStatus::Pending, - private_status: ApplicationStatus::Pending, - } - .insert(&connection) - .expect("Failed to insert application"); - - let application = NewApplication { - role_id: senior_mentor_role.id, - user_id: User::get_from_email(&connection, "shrey.somaiya@gmail.com") - .unwrap() - .id, - status: ApplicationStatus::Pending, - private_status: ApplicationStatus::Pending, - } - .insert(&connection) - .expect("Failed to insert application"); - - println!("... Creating hayes application\n"); - - // create answers to question one - let hayes_qn_one_answer = NewAnswer { - question_id: question_one.id, - application_id: application.id, - description: "42".to_string(), - } - .insert(&connection) - .expect("Failed to insert answer"); - - println!("... Creating hayes answer to question one\n"); - // lets create a rating for hayes from Giuliana - - let hayes_rating_from_giuliana = NewRating { - application_id: application.id, - rater_user_id: giuliana_csesoc_admin.user_id, - rating: 0, - } - .insert(&connection) - .expect("Failed to insert rating"); - - let hayes_rating_from_clarence = NewRating { - application_id: application.id, - rater_user_id: clarence_csesoc_director.user_id, - rating: 5, - } - .insert(&connection) - .expect("Failed to insert rating"); - - let hayes_comment_from_giuliana = NewComment { - application_id: application.id, - commenter_user_id: giuliana_csesoc_admin.user_id, - description: "bad answers".to_string(), - } - .insert(&connection) - .expect("Failed to insert comment"); - - let hayes_comment_from_clarence = NewComment { - application_id: application.id, - commenter_user_id: clarence_csesoc_director.user_id, - description: "love this guy <3".to_string(), - } - .insert(&connection) - .expect("Failed to insert comment"); - - println!("... Creating hayes comments and ratings\n"); -} diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index a0e60570b..a428e3a67 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -3,38 +3,30 @@ name = "server" version = "0.1.0" edition = "2021" - -[lib] -name = "backend" -path = "src/lib.rs" - -[[bin]] -name = "server" -path = "src/bin.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -rocket = { version = "0.5.0-rc.2", features = ["json"] } -rocket_sync_db_pools = { version = "0.1.0-rc.2", features = ["diesel_postgres_pool"] } -# pull rocket_cors from git master until crates.io artifact builds on stable -rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", branch = "master" } -diesel = { version = "1.4.8", features = ["postgres", "r2d2", "chrono"] } -diesel-derive-enum = { version = "1", features = ["postgres"] } -dotenv = "0.15.0" -dotenv_codegen = "0.15.0" -reqwest = { version = "0.11.13", features = ["json"] } -jsonwebtoken = "8.3.0" -serde = {version = "1.0", features = ["derive"] } -serde_json = "1.0.89" -chrono = { version = "0.4", features = ["serde"] } -itertools = "0.10.5" -once_cell = "1.18.0" -diesel_migrations = "1.4.0" -figment = { version = "0.10", features = ["env", "toml", "json"] } -image = "0.24.4" -strum = { version = "0.24", features = ["derive"] } -webp = "0.2" +# Primary crates +tokio = { version = "1.34", features = ["macros", "rt-multi-thread"] } +axum = { version = "0.7", features = ["macros"] } +axum-extra = { version = "0.9.6", features = ["typed-header", "cookie"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } -[dependencies.uuid] -version = "1.3.3" -features = ["v4", "fast-rng", "macro-diagnostics"] +# Important secondary crates +anyhow = "1.0" +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +reqwest = { version = "0.11", features = ["json"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +oauth2 = "4.4" +log = "0.4" +uuid = { version = "1.5", features = ["serde", "v4"] } +rust-s3 = "0.34.0" +rs-snowflake = "0.6" +jsonwebtoken = "9.1" +dotenvy = "0.15" +handlebars = "6.2" +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } +time = "0.3.37" +tower-http = { version = "0.6", features = ["cors"] } diff --git a/backend/server/src/Cargo.lock b/backend/server/src/Cargo.lock deleted file mode 100644 index df30c94f9..000000000 --- a/backend/server/src/Cargo.lock +++ /dev/null @@ -1,2078 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "aho-corasick" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" -dependencies = [ - "memchr", -] - -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - -[[package]] -name = "async-stream" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171374e7e3b2504e0e5236e3b59260560f9fe94bfe9ac39ba5e4e929c5590625" -dependencies = [ - "async-stream-impl", - "futures-core", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "648ed8c8d2ce5409ccd57453d9d1b214b342a0d69376a6feda1fd6cae3299308" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c" -dependencies = [ - "autocfg", -] - -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - -[[package]] -name = "autocfg" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" - -[[package]] -name = "backend" -version = "0.1.0" -dependencies = [ - "chrono", - "diesel", - "diesel-derive-enum", - "dotenv", - "dotenv_codegen", - "jsonwebtoken", - "reqwest", - "rocket", - "rocket_cors", - "rocket_sync_db_pools", - "serde", - "serde_json", -] - -[[package]] -name = "base-x" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b" - -[[package]] -name = "base64" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" - -[[package]] -name = "base64" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" - -[[package]] -name = "binascii" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bumpalo" -version = "3.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" - -[[package]] -name = "cc" -version = "1.0.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" -dependencies = [ - "libc", - "num-integer", - "num-traits", - "serde", - "time 0.1.43", - "winapi", -] - -[[package]] -name = "const_fn" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7" - -[[package]] -name = "cookie" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f1c7727e460397e56abc4bddc1d49e07a1ad78fc98eb2e1c8f032a58a2f80d" -dependencies = [ - "percent-encoding", - "time 0.2.27", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6888e10551bb93e424d8df1d07f1a8b4fceb0001a3a4b048bfc47554946f47b3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "devise" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c7580b072f1c8476148f16e0a0d5dedddab787da98d86c5082c5e9ed8ab595" -dependencies = [ - "devise_codegen", - "devise_core", -] - -[[package]] -name = "devise_codegen" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "123c73e7a6e51b05c75fe1a1b2f4e241399ea5740ed810b0e3e6cacd9db5e7b2" -dependencies = [ - "devise_core", - "quote", -] - -[[package]] -name = "devise_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841ef46f4787d9097405cac4e70fb8644fc037b526e8c14054247c0263c400d0" -dependencies = [ - "bitflags", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - -[[package]] -name = "diesel" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28135ecf6b7d446b43e27e225622a038cc4e2930a1022f51cdb97ada19b8e4d" -dependencies = [ - "bitflags", - "byteorder", - "chrono", - "diesel_derives", - "pq-sys", - "r2d2", -] - -[[package]] -name = "diesel-derive-enum" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70806b70be328e646f243680a3fc93b3cfdd6db373faa5110660a5dd5af243bc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "diesel_derives" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f5098f628d02a7a0f68ddba586fb61e80edec3bdc1be3b921f4ceec60858d3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "discard" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" - -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - -[[package]] -name = "dotenv_codegen" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56966279c10e4f8ee8c22123a15ed74e7c8150b658b26c619c53f4a56eb4a8aa" -dependencies = [ - "dotenv_codegen_implementation", - "proc-macro-hack", -] - -[[package]] -name = "dotenv_codegen_implementation" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e737a3522cd45f6adc19b644ce43ef53e1e9045f2d2de425c1f468abd4cf33" -dependencies = [ - "dotenv", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" - -[[package]] -name = "encoding_rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a74ea89a0a1b98f6332de42c95baff457ada66d1cb4030f9ff151b2041a1c746" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "figment" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790b4292c72618abbab50f787a477014fe15634f96291de45672ce46afe122df" -dependencies = [ - "atomic", - "pear", - "serde", - "toml", - "uncased", - "version_check", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" -dependencies = [ - "matches", - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd0210d8c325c245ff06fd95a3b13689a1a276ac8cfa8e8720cb840bfb84b9e" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fc8cd39e3dbf865f7340dce6a2d401d24fd37c6fe6c4f0ee0de8bfca2252d27" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445" - -[[package]] -name = "futures-executor" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e481354db6b5c353246ccf6a728b0c5511d752c08da7260546fc0933869daa11" - -[[package]] -name = "futures-macro" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89f17b21645bc4ed773c69af9c9a0effd4a3f1a3876eadd453469f8854e7fdd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "996c6442437b62d21a32cd9906f9c41e7dc1e19a9579843fad948696769305af" - -[[package]] -name = "futures-task" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12" - -[[package]] -name = "futures-util" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generator" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d9279ca822891c1a4dae06d185612cf8fc6acfe5dff37781b41297811b12ee" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "winapi", -] - -[[package]] -name = "getrandom" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "glob" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" - -[[package]] -name = "h2" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd819562fcebdac5afc5c113c3ec36f902840b70fd4fc458799c8ce4607ae55" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" - -[[package]] -name = "heck" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "http" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1323096b05d41827dadeaee54c9981958c0f94e670bc94ed80037d1a7b8b186b" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" - -[[package]] -name = "httpdate" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" - -[[package]] -name = "hyper" -version = "0.14.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436ec0091e4f20e655156a30a0df3770fe2900aa301e548e08446ec794b6953c" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "idna" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" -dependencies = [ - "matches", - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "indexmap" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" -dependencies = [ - "autocfg", - "hashbrown", - "serde", -] - -[[package]] -name = "inlinable_string" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3094308123a0e9fd59659ce45e22de9f53fc1d2ac6e1feb9fef988e4f76cad77" - -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "ipnet" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" - -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - -[[package]] -name = "js-sys" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "7.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afabcc15e437a6484fc4f12d0fd63068fe457bf93f1c148d3d9649c60b103f32" -dependencies = [ - "base64 0.12.3", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98a04dce437184842841303488f70d0188c5f51437d2a834dc097eafa909a01" - -[[package]] -name = "lock_api" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "loom" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5c7d328e32cc4954e8e01193d7f0ef5ab257b5090b70a964e099a36034309" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matches" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" - -[[package]] -name = "memchr" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" - -[[package]] -name = "mime" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" - -[[package]] -name = "mio" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" -dependencies = [ - "libc", - "log", - "miow", - "ntapi", - "winapi", -] - -[[package]] -name = "miow" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" -dependencies = [ - "winapi", -] - -[[package]] -name = "multer" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "408327e2999b839cd1af003fc01b2019a6c10a1361769542203f6fedc5179680" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "log", - "mime", - "spin 0.9.2", - "tokio", - "tokio-util", - "twoway", - "version_check", -] - -[[package]] -name = "native-tls" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" -dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "ntapi" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" -dependencies = [ - "winapi", -] - -[[package]] -name = "num-bigint" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "once_cell" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" - -[[package]] -name = "openssl" -version = "0.10.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-sys", -] - -[[package]] -name = "openssl-probe" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" - -[[package]] -name = "openssl-sys" -version = "0.9.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73" -dependencies = [ - "autocfg", - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall", - "smallvec", - "winapi", -] - -[[package]] -name = "pear" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15e44241c5e4c868e3eaa78b7c1848cadd6344ed4f54d029832d32b415a58702" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a5ca643c2303ecb740d506539deba189e16f2754040a42901cd8105d0282d0" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - -[[package]] -name = "pem" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" -dependencies = [ - "base64 0.13.0", - "once_cell", - "regex", -] - -[[package]] -name = "percent-encoding" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" - -[[package]] -name = "pin-project-lite" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1a3ea4f0dd7f1f3e512cf97bf100819aa547f36a6eccac8dbaae839eb92363e" - -[[package]] -name = "ppv-lite86" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba" - -[[package]] -name = "pq-sys" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac25eee5a0582f45a67e837e350d784e7003bd29a5f460796772061ca49ffda" -dependencies = [ - "vcpkg", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" - -[[package]] -name = "proc-macro2" -version = "1.0.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb37d2df5df740e582f28f8560cf425f52bb267d872fe58358eadb554909f07a" -dependencies = [ - "unicode-xid", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - -[[package]] -name = "quote" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r2d2" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "rand" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", - "rand_hc", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rand_hc" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" -dependencies = [ - "rand_core", -] - -[[package]] -name = "redox_syscall" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300f2a835d808734ee295d45007adacb9ebb29dd3ae2424acfa17930cae541da" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c38e3aecd2b21cb3959637b883bb3714bc7e43f0268b9a29d3743ee3e55cdd2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.6.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" - -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - -[[package]] -name = "reqwest" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bea77bc708afa10e59905c3d4af7c8fd43c9214251673095ff8b14345fcbc5" -dependencies = [ - "base64 0.13.0", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "hyper-tls", - "ipnet", - "js-sys", - "lazy_static", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tokio-native-tls", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted", - "web-sys", - "winapi", -] - -[[package]] -name = "rocket" -version = "0.5.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a71c18c42a0eb15bf3816831caf0dad11e7966f2a41aaf486a701979c4dd1f2" -dependencies = [ - "async-stream", - "async-trait", - "atomic", - "atty", - "binascii", - "bytes", - "either", - "figment", - "futures", - "indexmap", - "log", - "memchr", - "multer", - "num_cpus", - "parking_lot", - "pin-project-lite", - "rand", - "ref-cast", - "rocket_codegen", - "rocket_http", - "serde", - "serde_json", - "state", - "tempfile", - "time 0.2.27", - "tokio", - "tokio-stream", - "tokio-util", - "ubyte", - "version_check", - "yansi", -] - -[[package]] -name = "rocket_codegen" -version = "0.5.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66f5fa462f7eb958bba8710c17c5d774bbbd59809fa76fb1957af7e545aea8bb" -dependencies = [ - "devise", - "glob", - "indexmap", - "proc-macro2", - "quote", - "rocket_http", - "syn", - "unicode-xid", -] - -[[package]] -name = "rocket_cors" -version = "0.5.2" -source = "git+https://github.com/lawliet89/rocket_cors?branch=master#2ec5b3e0918c5ed634baeec3d1948f096f3c534d" -dependencies = [ - "log", - "regex", - "rocket", - "serde", - "serde_derive", - "unicase", - "unicase_serde", - "url", -] - -[[package]] -name = "rocket_http" -version = "0.5.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c8b7d512d2fcac2316ebe590cde67573844b99e6cc9ee0f53375fa16e25ebd" -dependencies = [ - "cookie", - "either", - "http", - "hyper", - "indexmap", - "log", - "memchr", - "mime", - "parking_lot", - "pear", - "percent-encoding", - "pin-project-lite", - "ref-cast", - "serde", - "smallvec", - "stable-pattern", - "state", - "time 0.2.27", - "tokio", - "uncased", -] - -[[package]] -name = "rocket_sync_db_pools" -version = "0.1.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38cfdfebd552d075c368e641c88a5cd6ce1c58c5c710548aeb777abb48830f4b" -dependencies = [ - "diesel", - "r2d2", - "rocket", - "rocket_sync_db_pools_codegen", - "serde", - "tokio", -] - -[[package]] -name = "rocket_sync_db_pools_codegen" -version = "0.1.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267808c094db5366e1d8925aaf9f2ce05ff9b3bd92cb18c7040a1fe219c2e25" -dependencies = [ - "devise", - "quote", -] - -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver", -] - -[[package]] -name = "rustversion" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" - -[[package]] -name = "ryu" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c9613b5a66ab9ba26415184cfc41156594925a9cf3a2057e57f31ff145f6568" - -[[package]] -name = "schannel" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" -dependencies = [ - "lazy_static", - "winapi", -] - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "scoped-tls" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" - -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "security-framework" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525bc1abfda2e1998d152c45cf13e696f76d0a4972310b22fac1658b05df7c87" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9dd14d83160b528b7bfd66439110573efcfbe281b17fc2ca9f39f550d619c7e" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - -[[package]] -name = "serde" -version = "1.0.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" - -[[package]] -name = "sharded-slab" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" -dependencies = [ - "libc", -] - -[[package]] -name = "simple_asn1" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692ca13de57ce0613a363c8c2f1de925adebc81b04c923ac60c5488bb44abe4b" -dependencies = [ - "chrono", - "num-bigint", - "num-traits", -] - -[[package]] -name = "slab" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" - -[[package]] -name = "smallvec" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" - -[[package]] -name = "socket2" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dc90fe6c7be1a323296982db1836d1ea9e47b6839496dde9a541bc496df3516" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511254be0c5bcf062b019a6c89c01a664aa359ded62f78aa72c6fc137c0590e5" - -[[package]] -name = "stable-pattern" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" -dependencies = [ - "memchr", -] - -[[package]] -name = "standback" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" -dependencies = [ - "version_check", -] - -[[package]] -name = "state" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cf4f5369e6d3044b5e365c9690f451516ac8f0954084622b49ea3fde2f6de5" -dependencies = [ - "loom", -] - -[[package]] -name = "stdweb" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" -dependencies = [ - "discard", - "rustc_version", - "stdweb-derive", - "stdweb-internal-macros", - "stdweb-internal-runtime", - "wasm-bindgen", -] - -[[package]] -name = "stdweb-derive" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_derive", - "syn", -] - -[[package]] -name = "stdweb-internal-macros" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" -dependencies = [ - "base-x", - "proc-macro2", - "quote", - "serde", - "serde_derive", - "serde_json", - "sha1", - "syn", -] - -[[package]] -name = "stdweb-internal-runtime" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" - -[[package]] -name = "syn" -version = "1.0.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "tempfile" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" -dependencies = [ - "cfg-if", - "libc", - "rand", - "redox_syscall", - "remove_dir_all", - "winapi", -] - -[[package]] -name = "thread_local" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" -dependencies = [ - "once_cell", -] - -[[package]] -name = "time" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "time" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" -dependencies = [ - "const_fn", - "libc", - "standback", - "stdweb", - "time-macros", - "version_check", - "winapi", -] - -[[package]] -name = "time-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" -dependencies = [ - "proc-macro-hack", - "time-macros-impl", -] - -[[package]] -name = "time-macros-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" -dependencies = [ - "proc-macro-hack", - "proc-macro2", - "quote", - "standback", - "syn", -] - -[[package]] -name = "tinyvec" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" - -[[package]] -name = "tokio" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e992e41e0d2fb9f755b37446f20900f64446ef54874f40a60c78f021ac6144" -dependencies = [ - "autocfg", - "bytes", - "libc", - "memchr", - "mio", - "num_cpus", - "once_cell", - "pin-project-lite", - "signal-hook-registry", - "tokio-macros", - "winapi", -] - -[[package]] -name = "tokio-macros" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9efc1aba077437943f7515666aa2b882dfabfbfdf89c819ea75a8d6e9eaba5e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e99e1983e5d376cd8eb4b66604d2e99e79f5bd988c3055891dcd8c9e2604cc0" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "log", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" -dependencies = [ - "serde", -] - -[[package]] -name = "tower-service" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" - -[[package]] -name = "tracing" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" -dependencies = [ - "cfg-if", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "tracing-log" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6923477a48e41c1951f1999ef8bb5a3023eb723ceadafe78ffb65dc366761e3" -dependencies = [ - "lazy_static", - "log", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245da694cc7fc4729f3f418b304cb57789f1bed2a78c575407ab8a23f53cb4d3" -dependencies = [ - "ansi_term", - "lazy_static", - "matchers", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" - -[[package]] -name = "twoway" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47" -dependencies = [ - "memchr", - "unchecked-index", -] - -[[package]] -name = "ubyte" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42756bb9e708855de2f8a98195643dff31a97f0485d90d8467b39dc24be9e8fe" -dependencies = [ - "serde", -] - -[[package]] -name = "uncased" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baeed7327e25054889b9bd4f975f32e5f4c5d434042d59ab6cd4142c0a76ed0" -dependencies = [ - "serde", - "version_check", -] - -[[package]] -name = "unchecked-index" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" - -[[package]] -name = "unicase" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" -dependencies = [ - "version_check", -] - -[[package]] -name = "unicase_serde" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef53697679d874d69f3160af80bc28de12730a985d57bdf2b47456ccb8b11f1" -dependencies = [ - "serde", - "unicase", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" - -[[package]] -name = "unicode-normalization" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" - -[[package]] -name = "unicode-xid" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "url" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" -dependencies = [ - "form_urlencoded", - "idna", - "matches", - "percent-encoding", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" - -[[package]] -name = "want" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" -dependencies = [ - "log", - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" - -[[package]] -name = "wasm-bindgen" -version = "0.2.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" -dependencies = [ - "bumpalo", - "lazy_static", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8d7523cb1f2a4c96c1317ca690031b714a51cc14e05f712446691f413f5d39" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc" - -[[package]] -name = "web-sys" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "winreg" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" -dependencies = [ - "winapi", -] - -[[package]] -name = "yansi" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" diff --git a/backend/server/src/admin.rs b/backend/server/src/admin.rs deleted file mode 100644 index ce942ba58..000000000 --- a/backend/server/src/admin.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::database::{ - models::{AdminInfoResponse, OrganisationInfo, OrganisationUser, SuperUser, User}, - Database, -}; -use crate::error::JsonErr; -use rocket::{get, http::Status, post, serde::json::Json}; - -#[get("/")] -pub async fn get(user: User, db: Database) -> Json { - Json(AdminInfoResponse { - organisations: db - .run(move |conn| { - user.get_all_org_ids_belonging(conn) - .into_iter() - .filter(|org_id| { - OrganisationUser::organisation_admin_level(*org_id, user.id, conn) - .is_at_least_director() - .check() - .is_ok() - }) - .map(|org| OrganisationInfo::new(org, conn)) - .collect::>() - }) - .await, - }) -} - -#[post("/make_superuser", data = "")] -pub async fn make_superuser( - _user: SuperUser, - db: Database, - email: Json, -) -> Result<(), JsonErr<()>> { - db.run(move |conn| { - User::get_from_email(conn, &email.into_inner()) - .map(|user| user.make_superuser(conn).ok()) - .flatten() - }) - .await - .ok_or(JsonErr((), Status::BadRequest)) -} diff --git a/backend/server/src/application.rs b/backend/server/src/application.rs deleted file mode 100644 index 907984963..000000000 --- a/backend/server/src/application.rs +++ /dev/null @@ -1,292 +0,0 @@ -use diesel::prelude::*; - -use crate::database::{ - models::{ - Answer, Application, Campaign, Comment, NewAnswer, NewApplication, NewRating, - OrganisationUser, Question, Rating, Role, User, - }, - schema::ApplicationStatus, - Database, -}; -use crate::error::JsonErr; -use rocket::{ - get, - http::Status, - post, put, - serde::{json::Json, Deserialize, Serialize}, - FromForm, -}; - -#[derive(Serialize)] -pub enum ApplicationError { - Unauthorized, - UserNotFound, - RoleNotFound, - UnableToCreate, - UnableToUpdate, - AppNotFound, - QuestionNotFound, - InvalidInput, - CampaignEnded, -} - -#[derive(Deserialize)] -pub struct ApplicationReq { - pub role_id: i32, -} - -#[post("/", data = "")] -pub async fn create_application( - app_req: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - use crate::database::schema::applications::dsl::*; - use diesel::prelude::*; - use diesel::query_dsl::RunQueryDsl; - - let new_application = NewApplication { - user_id: user.id, - role_id: app_req.role_id, - status: ApplicationStatus::Pending, - private_status: ApplicationStatus::Pending, - }; - - let application = db - .run(move |conn| { - let role = Role::get_from_id(conn, app_req.role_id)?; - let camp = Campaign::get_from_id(conn, role.campaign_id)?; - - if !camp.is_running() { - return None; - } - - let count = applications - .filter(role_id.eq(app_req.role_id).and(user_id.eq(user.id))) - .select(id) - .load::(conn) - .unwrap_or_else(|_| vec![]) - .len(); - - if count > 0 { - return None; - } - - NewApplication::insert(&new_application, conn) - }) - .await - .ok_or(JsonErr( - ApplicationError::UnableToCreate, - Status::BadRequest, - ))?; - - Ok(Json(application)) -} - -#[derive(FromForm, Deserialize)] -pub struct RatingInput { - rating: i32, -} - -#[put("//rating", data = "")] -pub async fn create_rating( - application_id: i32, - rating: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - OrganisationUser::application_admin_level(application_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - NewRating::insert( - &NewRating { - application_id, - rater_user_id: user.id, - rating: rating.rating, - }, - &conn, - ) - .ok_or(JsonErr( - ApplicationError::UnableToCreate, - Status::InternalServerError, - ))?; - - Ok(Json(())) - }) - .await -} - -#[post("/answer", data = "")] -pub async fn submit_answer( - user: User, - db: Database, - answer: Json, -) -> Result, JsonErr> { - db.run(move |conn| { - let application = Application::get(answer.application_id, &conn) - .ok_or(JsonErr(ApplicationError::AppNotFound, Status::NotFound))?; - if application.user_id != user.id { - return Err(JsonErr(ApplicationError::Unauthorized, Status::Forbidden)); - } - - let question = Question::get_from_id(&conn, answer.question_id).ok_or(JsonErr( - ApplicationError::QuestionNotFound, - Status::BadRequest, - ))?; - if answer.description.len() as i32 > question.max_bytes { - return Err(JsonErr(ApplicationError::InvalidInput, Status::BadRequest)); - } - - NewAnswer::insert(&answer, &conn).ok_or(JsonErr( - ApplicationError::UnableToCreate, - Status::InternalServerError, - ))?; - - Ok(Json(())) - }) - .await -} - -#[derive(Serialize)] -pub struct AnswersResponse { - answers: Vec, -} - -#[get("//answers")] -pub async fn get_answers( - application_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let app = Application::get(application_id, &conn) - .ok_or(JsonErr(ApplicationError::AppNotFound, Status::NotFound))?; - - OrganisationUser::role_admin_level(app.role_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - Ok(Json(AnswersResponse { - answers: Answer::get_all_from_application_id(conn, application_id), - })) - }) - .await -} - -#[derive(Serialize)] -pub struct RatingsResponse { - ratings: Vec, -} - -#[get("//ratings")] -pub async fn get_ratings( - application_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let app = Application::get(application_id, &conn) - .ok_or(JsonErr(ApplicationError::AppNotFound, Status::NotFound))?; - - OrganisationUser::role_admin_level(app.role_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - Ok(Json(RatingsResponse { - ratings: Rating::get_all_from_application_id(conn, application_id), - })) - }) - .await -} - -#[put("//status", data = "")] -pub async fn set_status( - application_id: i32, - new_status: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - use crate::database::schema::applications::dsl::*; - - db.run(move |conn| { - OrganisationUser::application_admin_level(application_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - diesel::update(applications.filter(id.eq(application_id))) - .set(status.eq(new_status.into_inner())) - .execute(conn) - .map_err(|_| { - JsonErr( - ApplicationError::UnableToUpdate, - Status::InternalServerError, - ) - })?; - - Ok(Json(())) - }) - .await -} - -#[put("//private_status", data = "")] -pub async fn set_private_status( - application_id: i32, - new_status: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - use crate::database::schema::applications::dsl::*; - - db.run(move |conn| { - OrganisationUser::application_admin_level(application_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - diesel::update(applications.filter(id.eq(application_id))) - .set(private_status.eq(new_status.into_inner())) - .execute(conn) - .map_err(|_| { - JsonErr( - ApplicationError::UnableToUpdate, - Status::InternalServerError, - ) - })?; - - Ok(Json(())) - }) - .await -} - -#[derive(Serialize)] -pub struct CommentsResponse { - comments: Vec, -} - -#[get("//comments")] -pub async fn get_comments( - application_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let app = Application::get(application_id, &conn) - .ok_or(JsonErr(ApplicationError::AppNotFound, Status::NotFound))?; - - OrganisationUser::role_admin_level(app.role_id, user.id, &conn) - .is_at_least_director() - .check() - .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; - - Ok(Json(CommentsResponse { - comments: Comment::get_all_from_application_id(conn, application_id), - })) - }) - .await -} diff --git a/backend/server/src/auth.rs b/backend/server/src/auth.rs deleted file mode 100644 index 4f7e805ea..000000000 --- a/backend/server/src/auth.rs +++ /dev/null @@ -1,376 +0,0 @@ -use crate::error::JsonErr; -use crate::{ - database::{ - models::{NewUser, User}, - schema::UserGender, - Database, - }, - state::ApiState, -}; -use dotenv; -use jsonwebtoken::{Algorithm, Header, Validation}; -use reqwest::header; -use rocket::{ - http::Status, - post, - request::FromRequest, - request::{self, Outcome}, - serde::json::Json, - Request, State, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; -const OPENID_EMAIL_FIELD: &str = "email"; -const OPENID_NAME_FIELD: &str = "name"; - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthJwt { - pub user_id: u32, -} - -pub struct Auth { - pub jwt: AuthJwt, -} - -#[derive(Debug)] -pub enum AuthError { - MissingAuthorizationHeader, - MissingBearer, - InvalidJwt, - ApiStateMissing, - NotSuperUser, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for Auth { - type Error = AuthError; - - async fn from_request(request: &'r Request<'_>) -> request::Outcome { - let api_state = match request.guard::<&State>().await { - Outcome::Success(api_state) => api_state, - _ => { - return Outcome::Failure((Status::InternalServerError, AuthError::ApiStateMissing)) - } - }; - - let header = match request.headers().get_one("Authorization") { - Some(header) => header, - None => { - return Outcome::Failure(( - Status::BadRequest, - AuthError::MissingAuthorizationHeader, - )) - } - }; - - if !header.starts_with("Bearer ") { - return Outcome::Failure((Status::BadRequest, AuthError::MissingBearer)); - } - - let token = header.trim_start_matches("Bearer "); - - let mut validation = Validation::new(Algorithm::HS256); - validation.validate_exp = false; - validation.required_spec_claims.clear(); - - let token = match jsonwebtoken::decode::( - token, - &api_state.jwt_decoding_key, - &validation, - ) { - Ok(data) => data.claims, - Err(_) => return Outcome::Failure((Status::BadRequest, AuthError::InvalidJwt)), - }; - - return Outcome::Success(Auth { jwt: token }); - } -} - -async fn get_access_token(oauth_code: &str, state: &State) -> Option { - #[derive(Serialize)] - struct TokenForm<'a> { - code: &'a str, - client_id: String, - client_secret: String, - redirect_uri: String, - grant_type: &'static str, - } - - #[derive(Deserialize)] - #[allow(unused)] - struct TokenResponse { - access_token: String, - expires_in: u32, - scope: String, - token_type: String, - id_token: String, - } - - let token_json = state - .reqwest_client - .post(GOOGLE_TOKEN_URL) - .form(&TokenForm { - code: oauth_code, - client_id: dotenv::var("GOOGLE_CLIENT_ID").expect("GOOGLE_CLIENT_ID should be in env"), - client_secret: dotenv::var("GOOGLE_CLIENT_SECRET") - .expect("GOOGLE_CLIENT_SECRET should be in env"), - redirect_uri: dotenv::var("GOOGLE_REDIRECT_URI") - .expect("GOOGLE_CLIENT_SECRET should be in env"), - grant_type: "authorization_code", - }) - .send() - .await - .map_err(|e| eprintln!("Oauth request failed: {}", e)) - .ok()? - .json::() - .await - .ok()?; - - match serde_json::from_value::(token_json.clone()) { - Ok(t) => Some(t.access_token), - Err(e) => { - eprintln!( - "Failed to parse token response: {}\nJSON is {}", - e, token_json - ); - None - } - } -} - -struct UserDetails { - email: Option, - name: Option, -} - -async fn get_user_details(state: &State, token: &str) -> UserDetails { - let response = match state - .reqwest_client - .get(&state.userinfo_endpoint) - .header(header::AUTHORIZATION, format!("Bearer {}", token)) - .send() - .await - { - Ok(response) => response, - Err(_) => { - return UserDetails { - email: None, - name: None, - } - } - }; - - let mut user_info = match response.json::().await { - Ok(user_info) => user_info, - Err(_) => { - return UserDetails { - email: None, - name: None, - } - } - }; - - let email = match user_info.get_mut(OPENID_EMAIL_FIELD) { - Some(Value::String(email)) => Some(email.to_string()), - _ => None, - }; - - let name = match user_info.get_mut(OPENID_NAME_FIELD) { - Some(Value::String(name)) => Some(name.to_string()), - _ => None, - }; - - UserDetails { email, name } -} - -#[derive(Deserialize)] -pub struct SignInBody { - oauth_token: String, -} - -#[derive(Serialize)] -pub struct SignInResponse { - token: String, - name: String, -} - -#[derive(Serialize)] -pub enum SignInError { - InvalidOAuthCode, - GoogleOAuthInternalError, - SignupRequired { - signup_token: String, - name: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SignupJwt { - pub auth_token: String, -} - -#[post("/signin", data = "")] -pub async fn signin( - body: Json, - state: &State, - db: Database, -) -> Result, JsonErr> { - let token = get_access_token(&body.oauth_token, state) - .await - .ok_or_else(|| { - eprintln!( - "Failed to get access token for oauth token {}", - body.oauth_token - ); - JsonErr(SignInError::InvalidOAuthCode, Status::Forbidden) - })?; - - let details = get_user_details(state, &token).await; - - let email = details.email.ok_or(JsonErr( - SignInError::GoogleOAuthInternalError, - Status::Forbidden, - ))?; - - let user = db - .run(move |conn| User::get_from_email(conn, &email)) - .await - .ok_or_else(|| { - let jwt = SignupJwt { auth_token: token }; - - let token = jsonwebtoken::encode( - &Header::new(jsonwebtoken::Algorithm::HS256), - &jwt, - &state.jwt_encoding_key, - ) - .expect("creating jwt should never fail"); - - JsonErr( - SignInError::SignupRequired { - signup_token: token, - name: details.name, - }, - Status::Ok, - ) - })?; - - let auth = AuthJwt { - user_id: user.id as u32, - }; - - let token = jsonwebtoken::encode( - &Header::new(jsonwebtoken::Algorithm::HS256), - &auth, - &state.jwt_encoding_key, - ) - .expect("creating jwt should never fail"); - - Ok(Json(SignInResponse { - token, - name: user.display_name, - })) -} - -#[derive(Deserialize, Clone)] -pub struct SignUpBody { - signup_token: String, - zid: String, - display_name: String, - degree_name: String, - degree_starting_year: u32, - gender: UserGender, - pronouns: String, -} - -#[derive(Serialize)] -pub struct SignUpResponse { - token: String, -} - -#[derive(Serialize)] -pub enum SignUpError { - InvalidSignupToken, - GoogleOAuthInternalError, - AccountAlreadyExists, -} - -#[post("/signup", data = "")] -pub async fn signup( - body: Json, - state: &State, - db: Database, -) -> Result, JsonErr> { - let mut validation = Validation::new(Algorithm::HS256); - validation.validate_exp = false; - validation.required_spec_claims.clear(); - - let token = match jsonwebtoken::decode::( - &body.signup_token, - &state.jwt_decoding_key, - &validation, - ) { - Ok(data) => data.claims.auth_token, - Err(_) => { - return Err(JsonErr( - SignUpError::InvalidSignupToken, - Status::FailedDependency, - )) - } - }; - - let details = get_user_details(state, &token).await; - - let email = details.email.ok_or(JsonErr( - SignUpError::GoogleOAuthInternalError, - Status::FailedDependency, - ))?; - - { - let email = email.clone(); - - if db - .run(move |conn| User::get_from_email(conn, &email)) - .await - .is_some() - { - return Err(JsonErr(SignUpError::AccountAlreadyExists, Status::ImUsed)); - } - } - - let user = { - let email = email.clone(); - let body = body.clone(); - - db.run(move |conn| { - let user = NewUser { - email, - zid: body.zid.to_string(), - display_name: body.display_name.to_string(), - degree_name: body.degree_name.to_string(), - degree_starting_year: body.degree_starting_year as i32, - gender: body.gender, - pronouns: body.pronouns.to_string(), - superuser: User::get_number(conn) == 0, - }; - - user.insert(conn) - .expect("we already ensured a conflicting user does not exist") - }) - .await - }; - - let auth = AuthJwt { - user_id: user.id as u32, - }; - - let token = jsonwebtoken::encode( - &Header::new(jsonwebtoken::Algorithm::HS256), - &auth, - &state.jwt_encoding_key, - ) - .expect("creating jwt should never fail"); - - Ok(Json(SignUpResponse { token })) -} diff --git a/backend/server/src/bin.rs b/backend/server/src/bin.rs deleted file mode 100644 index f15465577..000000000 --- a/backend/server/src/bin.rs +++ /dev/null @@ -1,170 +0,0 @@ -extern crate diesel; - -#[macro_use] -extern crate diesel_migrations; - -use backend::cors::cors; -use backend::database::Database; -use backend::{auth::Auth, images::IMAGE_BASE_PATH}; -use diesel::pg::PgConnection; -use diesel::prelude::*; -use diesel_migrations::*; -use figment::{providers::Serialized, Figment}; -use rocket::{routes, serde::json::Value}; -use std::{env, fs, path::Path}; - -#[rocket::get("/foo")] -fn authed_call(auth: Auth) -> String { - format!("hello, your user id is {}", auth.jwt.user_id) -} - -embed_migrations!(); - -#[rocket::main] -async fn main() { - dotenv::dotenv().ok(); - - let db_url = run_migrations(); - - let api_state = backend::state::api_state().await; - - let cors = cors(); - - let config_map: Value = serde_json::from_str(&format!( - r#"{{ - "databases": {{ - "database": {{ - "url": "{}" - }} - }}, - "log_level": "debug", - "address": "0.0.0.0", - "limits": {{ - "json": "10485760" - }} - }}"#, - db_url - )) - .unwrap(); - - let figment = Figment::from(rocket::Config::default()).merge(Serialized::globals(config_map)); - - // create images dir if not found - fs::create_dir_all(Path::new(IMAGE_BASE_PATH)).ok(); - - let _ = rocket::custom(figment) - .manage(api_state) - .attach(Database::fairing()) - .attach(cors) - .mount("/", routes![authed_call]) - .mount( - "/organisation", - routes![ - backend::organisation::new, - backend::organisation::get_from_id, - backend::organisation::delete, - backend::organisation::get_admins, - backend::organisation::set_admins, - backend::organisation::is_admin, - backend::organisation::get_from_ids, - backend::organisation::invite_uid, - backend::organisation::invite_email, - backend::organisation::set_logo, - ], - ) - .mount( - "/auth", - routes![backend::auth::signin, backend::auth::signup], - ) - .mount( - "/campaign", - routes![ - backend::campaigns::get, - backend::campaigns::update, - backend::campaigns::roles, - backend::campaigns::new, - backend::campaigns::delete_campaign, - backend::campaigns::get_all_campaigns, - backend::campaigns::set_cover_image, - ], - ) - .mount( - "/user", - routes![ - backend::user::get_user, - backend::user::get_user_campaigns, - backend::user::get, - ], - ) - .mount( - "/application", - routes![ - backend::application::create_application, - backend::application::create_rating, - backend::application::submit_answer, - backend::application::get_answers, - backend::application::get_ratings, - backend::application::set_status, - backend::application::set_private_status, - backend::application::get_comments, - ], - ) - .mount( - "/role", - routes![ - backend::role::get_role, - backend::role::update_role, - backend::role::new_role, - backend::role::get_questions, - backend::role::get_applications, - ], - ) - .mount( - "/comment", - routes![ - backend::comment::create_comment, - backend::comment::get_comment_from_id - ], - ) - .mount( - "/question", - routes![ - backend::question::get_question, - backend::question::edit_question, - backend::question::delete_question - ], - ) - .mount( - "/admin", - routes![backend::admin::get, backend::admin::make_superuser], - ) - .mount("/static", routes![backend::static_resources::files]) - .launch() - .await - .unwrap(); -} - -fn run_migrations() -> String { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - assert!(&database_url[database_url.len() - 5..] == "chaos"); - - let database_url_no_chaos = String::from(&database_url[..database_url.len() - 5]); - - let main_connection = PgConnection::establish(&database_url_no_chaos) - .expect(&format!("Error connecting to {}", database_url_no_chaos)); - - match diesel::sql_query("CREATE DATABASE chaos").execute(&main_connection) { - _ => (), - }; - - let chaos_connection = PgConnection::establish(&database_url) - .expect(&format!("Error connecting to {}", database_url)); - - embedded_migrations::run_with_output(&chaos_connection, &mut std::io::stdout()) - .expect("Failed to run migrations"); - - println!("Finishing running migrations"); - - return database_url; -} diff --git a/backend/server/src/campaigns.rs b/backend/server/src/campaigns.rs deleted file mode 100644 index ca64a8eb1..000000000 --- a/backend/server/src/campaigns.rs +++ /dev/null @@ -1,308 +0,0 @@ -use crate::error::JsonErr; -use crate::images::get_image_path; -use crate::{ - database::{ - models::{ - Campaign, CampaignWithRoles, NewCampaignInput, NewQuestion, OrganisationUser, Role, - RoleUpdate, UpdateCampaignInput, User, - }, - Database, - }, - images::{get_http_image_path, save_image, try_decode_data, ImageLocation}, -}; -use rocket::{data::Data, delete, get, http::Status, post, put, serde::json::Json}; -use serde::{Deserialize, Serialize}; -use std::fs::remove_file; - -use uuid::Uuid; - -#[derive(Serialize)] -pub enum CampaignError { - CampaignNotFound, - Unauthorized, - UnableToCreate, - InvalidInput, -} - -#[get("/")] -pub async fn get(campaign_id: i32, db: Database) -> Result, JsonErr> { - let mut campaign = db - .run(move |conn| Campaign::get_from_id(conn, campaign_id)) - .await; - - campaign = campaign.map(|mut campaign| { - campaign.cover_image = campaign - .cover_image - .map(|logo_uuid| get_http_image_path(ImageLocation::ORGANISATIONS, &logo_uuid)); - campaign - }); - - match campaign { - Some(campaign) => { - if campaign.published { - Ok(Json(campaign)) - } else { - Err(JsonErr(CampaignError::Unauthorized, Status::Forbidden)) - } - } - None => Err(JsonErr(CampaignError::CampaignNotFound, Status::NotFound)), - } -} - -#[derive(Serialize)] -pub struct DashboardCampaignGroupings { - pub current_campaigns: Vec, - pub past_campaigns: Vec, -} - -#[get("/all")] -pub async fn get_all_campaigns(user: User, db: Database) -> Json { - fn with_http_cover_images(campaigns: Vec) -> Vec { - campaigns - .into_iter() - .map(CampaignWithRoles::with_http_cover_image) - .collect() - } - - let (current_campaigns, past_campaigns) = db - .run(move |conn| { - ( - with_http_cover_images(Campaign::get_all_public_with_roles(conn, user.id)), - with_http_cover_images(Campaign::get_all_public_ended_with_roles(conn, user.id)), - ) - }) - .await; - - Json(DashboardCampaignGroupings { - current_campaigns, - past_campaigns, - }) -} - -#[put("/", data = "")] -pub async fn update( - campaign_id: i32, - update_campaign: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - OrganisationUser::campaign_admin_level(campaign_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(JsonErr(CampaignError::Unauthorized, Status::Forbidden)))?; - - Campaign::update(conn, campaign_id, &update_campaign); - - Ok(Json(())) - }) - .await -} - -#[derive(Serialize, Deserialize)] -pub struct RoleInput { - pub name: String, - pub description: Option, - pub min_available: i32, - pub max_available: i32, - pub questions_for_role: Vec, -} - -fn default_max_bytes() -> i32 { - 100 -} - -#[derive(Serialize, Deserialize)] -pub struct QuestionInput { - pub title: String, - pub description: Option, - #[serde(default = "default_max_bytes")] - pub max_bytes: i32, - #[serde(default)] - pub required: bool, -} - -#[derive(Deserialize)] -pub struct NewCampaignWithData { - pub campaign: NewCampaignInput, - pub roles: Vec, - pub questions: Vec, -} - -#[post("/", data = "")] -pub async fn new( - new_campaign: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - let inner = new_campaign.into_inner(); - let NewCampaignWithData { - campaign, - roles, - questions, - } = inner; - - let mut new_questions: Vec = questions - .into_iter() - .map(|x| NewQuestion { - role_ids: vec![], - title: x.title, - description: x.description, - max_bytes: x.max_bytes, - required: x.required, - }) - .collect(); - - db.run(move |conn| { - OrganisationUser::organisation_admin_level(campaign.organisation_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(JsonErr(CampaignError::Unauthorized, Status::Forbidden)))?; - - let campaign = Campaign::create(conn, &campaign).ok_or_else(|| { - eprintln!("Failed to create campaign for some reason: {:?}", campaign); - JsonErr(CampaignError::UnableToCreate, Status::InternalServerError) - })?; - - for role in roles { - let new_role = RoleUpdate { - campaign_id: campaign.id, - name: role.name, - description: role.description, - min_available: role.min_available, - max_available: role.max_available, - finalised: campaign.published, - }; - let inserted_role = new_role.insert(conn).ok_or_else(|| { - eprintln!("Failed to create role for some reason: {:?}", new_role); - JsonErr(CampaignError::UnableToCreate, Status::InternalServerError) - })?; - - for question in role.questions_for_role { - if question < new_questions.len() { - new_questions[question].role_ids.push(inserted_role.id); - } - } - } - - for question in new_questions { - if question.role_ids.len() == 0 { - return Err(JsonErr(CampaignError::InvalidInput, Status::BadRequest)); - } - question.insert(conn).ok_or_else(|| { - eprintln!("Failed to create question for some reason"); - JsonErr(CampaignError::UnableToCreate, Status::InternalServerError) - })?; - } - - Ok(Json(campaign)) - }) - .await -} - -#[delete("/")] -pub async fn delete_campaign( - campaign_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - // need to be admin to create new campaign - OrganisationUser::campaign_admin_level(campaign_id, user.id, &conn) - .is_admin() - .check() - .or_else(|_| Err(JsonErr(CampaignError::Unauthorized, Status::Forbidden)))?; - - Campaign::delete_deep(conn, campaign_id); - - Ok(Json(())) - }) - .await -} - -#[derive(Serialize)] -pub struct RolesResponse { - roles: Vec, -} - -#[derive(Serialize)] -pub enum RolesError { - CampaignNotFound, - Unauthorized, - RoleAlreadyExists, -} - -#[get("//roles")] -pub async fn roles( - campaign_id: i32, // campaign_id has namespace conflict - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let campaign = Campaign::get_from_id(conn, campaign_id) - .ok_or_else(|| JsonErr(RolesError::CampaignNotFound, Status::NotFound))?; - - OrganisationUser::campaign_admin_level(campaign_id, user.id, &conn) - .is_at_least_director() - .or(campaign.published) // only if not (read only and campaign is unpublished) - .check() - .or_else(|_| Err(JsonErr(RolesError::Unauthorized, Status::Forbidden)))?; - - let roles = Role::get_all_from_campaign_id(conn, campaign.id); - - Ok(Json(RolesResponse { roles })) - }) - .await -} - -#[derive(Serialize)] -pub enum LogoError { - Unauthorized, - ImageDeletionFailure, - ImageStoreFailure, -} - -#[put("//cover_image", data = "")] -pub async fn set_cover_image( - campaign_id: i32, - user: User, - db: Database, - image: Data<'_>, -) -> Result, JsonErr> { - db.run(move |conn| { - OrganisationUser::campaign_admin_level(campaign_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(JsonErr(LogoError::Unauthorized, Status::Forbidden))) - }) - .await?; - - let old_logo_uuid = db - .run(move |conn| Campaign::get_cover_image(&conn, campaign_id)) - .await; - let logo_uuid = Uuid::new_v4().as_hyphenated().to_string() + ".webp"; - - let image = try_decode_data(image).await.or_else(|_| { - Err(JsonErr( - LogoError::ImageDeletionFailure, - Status::InternalServerError, - )) - })?; - - save_image(image, ImageLocation::CAMPAIGNS, &logo_uuid) - .map_err(|_| JsonErr(LogoError::ImageStoreFailure, Status::InternalServerError))?; - - let logo_uuid_clone = logo_uuid.clone(); - - db.run(move |conn| Campaign::set_cover_image(&conn, campaign_id, &logo_uuid_clone)) - .await; - - if let Some(uuid) = old_logo_uuid { - remove_file(get_image_path(ImageLocation::CAMPAIGNS, &uuid)).ok(); - } - - Ok(Json(get_http_image_path( - ImageLocation::CAMPAIGNS, - &logo_uuid, - ))) -} diff --git a/backend/server/src/comment.rs b/backend/server/src/comment.rs deleted file mode 100644 index bd123cd41..000000000 --- a/backend/server/src/comment.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::database::{ - models::{Comment, NewComment, OrganisationUser, User}, - Database, -}; -use crate::error::JsonErr; -use rocket::{ - get, - http::Status, - put, - serde::{json::Json, Deserialize, Serialize}, -}; - -#[derive(Deserialize)] -pub struct NewCommentInput { - pub application_id: i32, - pub description: String, -} - -#[derive(Serialize)] -pub enum CommentError { - Unauthorized, - CouldNotInsert, - CommentNotFound, -} - -#[put("/", data = "")] -pub async fn create_comment( - new_comment_input: Json, - user: User, - db: Database, -) -> Result, JsonErr> { - // need to be director to comment - let app_id = new_comment_input.application_id; // stack copy of i32 - db.run(move |conn| { - OrganisationUser::application_admin_level(app_id, user.id, &conn) - .is_at_least_director() - .check() - }) - .await - .or_else(|_| Err(JsonErr(CommentError::Unauthorized, Status::Forbidden)))?; - - let new_comment = NewComment { - application_id: new_comment_input.application_id, - commenter_user_id: user.id, - description: new_comment_input.description.to_string(), - }; - let comment = db - .run(move |conn| NewComment::insert(&new_comment, conn)) - .await - .ok_or_else(|| JsonErr(CommentError::CouldNotInsert, Status::InternalServerError))?; - - Ok(Json(comment)) -} - -#[get("/")] -pub async fn get_comment_from_id( - comment_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - OrganisationUser::comment_admin_level(comment_id, user.id, &conn) - .is_at_least_director() - .check() - }) - .await - .or_else(|_| Err(JsonErr(CommentError::Unauthorized, Status::Forbidden)))?; - - let comment = db - .run(move |conn| Comment::get_from_id(conn, comment_id)) - .await - .ok_or_else(|| JsonErr(CommentError::CommentNotFound, Status::NotFound))?; - - Ok(Json(comment)) -} diff --git a/backend/server/src/cors.rs b/backend/server/src/cors.rs deleted file mode 100644 index 12db39f5d..000000000 --- a/backend/server/src/cors.rs +++ /dev/null @@ -1,21 +0,0 @@ -use rocket_cors::{AllowedHeaders, AllowedOrigins, Cors}; - -pub fn cors() -> Cors { - let cors = rocket_cors::CorsOptions { - allowed_origins: AllowedOrigins::All, - allowed_methods: { - // why do i have to do this, honestly - use rocket::http::Method::*; - vec![Get, Put, Post, Delete, Options, Head, Trace, Connect, Patch] - .into_iter() - .map(From::from) - .collect() - }, - allowed_headers: AllowedHeaders::All, - ..Default::default() - } - .to_cors() - .expect("Failed to create CORS options"); - - cors -} diff --git a/backend/server/src/database/mod.rs b/backend/server/src/database/mod.rs deleted file mode 100644 index 97015d397..000000000 --- a/backend/server/src/database/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod models; -pub mod schema; - -use rocket_sync_db_pools::database; - -#[database("database")] -pub struct Database(diesel::PgConnection); diff --git a/backend/server/src/database/models.rs b/backend/server/src/database/models.rs deleted file mode 100644 index d42360815..000000000 --- a/backend/server/src/database/models.rs +++ /dev/null @@ -1,1425 +0,0 @@ -use crate::images::{get_http_image_path, ImageLocation}; - -use super::schema::{ - answers, applications, campaigns, comments, organisation_users, organisations, questions, - ratings, roles, users, -}; -use super::schema::{AdminLevel, ApplicationStatus, UserGender}; -use chrono::NaiveDateTime; -use chrono::Utc; -use diesel::prelude::*; -use diesel::PgConnection; -use rocket::FromForm; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::fs::remove_file; -use std::path::Path; - -#[derive(Queryable)] -pub struct User { - pub id: i32, - pub email: String, - pub zid: String, - pub display_name: String, - pub degree_name: String, - pub degree_starting_year: i32, - pub gender: UserGender, - pub pronouns: String, - pub superuser: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -pub struct SuperUser { - user: User, -} - -impl SuperUser { - pub fn new(user: User) -> Self { - Self { user } - } - - pub fn user(&self) -> &User { - &self.user - } -} -#[derive(Insertable)] -#[table_name = "users"] -pub struct NewUser { - pub email: String, - pub zid: String, - pub display_name: String, - pub degree_name: String, - pub degree_starting_year: i32, - pub gender: UserGender, - pub pronouns: String, - pub superuser: bool, -} - -impl User { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::users::dsl::*; - - users.order(id.asc()).load(conn).unwrap_or_else(|_| vec![]) - } - - pub fn make_superuser(&self, conn: &PgConnection) -> Result<(), diesel::result::Error> { - use crate::database::schema::users::dsl::*; - - diesel::update(users.filter(id.eq(self.id))) - .set(superuser.eq(true)) - .execute(conn)?; - - Ok(()) - } - - pub fn get_number(conn: &PgConnection) -> i64 { - use crate::database::schema::users::dsl::*; - use diesel::dsl::count; - - users - .select(count(display_name)) - .first(conn) - .unwrap_or_else(|_| 0) - } - - pub fn get_from_id(conn: &PgConnection, id_val: i32) -> Option { - use crate::database::schema::users::dsl::*; - - users.filter(id.eq(id_val)).first(conn).ok() - } - - pub fn get_from_email(conn: &PgConnection, user_email: &str) -> Option { - use crate::database::schema::users::dsl::*; - - users.filter(email.eq(user_email)).first(conn).ok() - } - - pub fn get_all_campaigns(&self, conn: &PgConnection) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - campaigns - .filter(organisation_id.eq(self.id)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_org_ids_belonging(&self, conn: &PgConnection) -> Vec { - if self.superuser { - return organisations::table - .select(organisations::id) - .load::(conn) - .unwrap_or_else(|_| vec![]); - } - - organisation_users::table - .filter(organisation_users::user_id.eq(self.id)) - .select(organisation_users::organisation_id) - .load::(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_orgs_belonging(&self, conn: &PgConnection) -> Vec { - if self.superuser { - return organisations::table - .load::(conn) - .unwrap_or_else(|_| vec![]); - } - - organisation_users::table - .filter(organisation_users::user_id.eq(self.id)) - .inner_join( - organisations::table.on(organisations::id.eq(organisation_users::organisation_id)), - ) - .select(( - organisations::id, - organisations::name, - organisations::logo, - organisations::created_at, - organisations::updated_at, - )) - .load::(conn) - .unwrap_or_else(|_| vec![]) - } -} - -impl NewUser { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::users::dsl::*; - - self.insert_into(users).get_result(conn).ok() - } -} - -#[derive(Queryable, Serialize, Deserialize)] -pub struct Organisation { - pub id: i32, - pub name: String, - pub logo: Option, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable, FromForm, Deserialize)] -#[table_name = "organisations"] -pub struct NewOrganisation { - pub name: String, - pub logo: Option, -} - -impl Organisation { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::organisations::dsl::*; - - organisations - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_from_id(conn: &PgConnection, organisation_id: i32) -> Option { - use crate::database::schema::organisations::dsl::*; - - organisations - .filter(id.eq(organisation_id)) - .first(conn) - .ok() - } - - pub fn delete(conn: &PgConnection, organisation_id: i32) -> Option { - use crate::database::schema::organisations::dsl::*; - - let num = diesel::delete(organisations.filter(id.eq(organisation_id))) - .execute(conn) - .ok()?; - - if num > 0 { - Some(num) - } else { - None - } - } - - pub fn delete_deep(conn: &PgConnection, org_id: i32) -> Option<()> { - use crate::database::schema::organisation_users::dsl::*; - if let Some(logo) = Organisation::get_logo(conn, org_id) { - remove_file(Path::new(&logo)).ok(); - } - - let campaigns = Campaign::get_all_from_org_id(conn, org_id); - - for campaign in campaigns { - Campaign::delete_deep(conn, campaign.id)?; - } - - diesel::delete(organisation_users.filter(organisation_id.eq(org_id))) - .execute(conn) - .ok(); - - let num = Organisation::delete(conn, org_id)?; - - if num < 1 { - return None; - } - - Some(()) - } - - pub fn find_by_name(conn: &PgConnection, organisation_name: &str) -> Option { - use crate::database::schema::organisations::dsl::*; - - organisations - .filter(name.eq(organisation_name)) - .first(conn) - .ok() - } - - pub fn get_admin_ids(conn: &PgConnection, org_id: i32) -> Option> { - organisation_users::table - .filter(organisation_users::organisation_id.eq(org_id)) - .load::(conn) - .map(|org_users| { - org_users - .into_iter() - .filter(|org_user| org_user.admin_level == AdminLevel::Admin) - .map(|org_user| org_user.user_id) - .collect() - }) - .ok() - } - - // FIXME - rather than looping through all admins, filter the users if theyre in admin_ids - // FIXME - this only works if they're already in the organisation, need to insert them into the - // organistaion first? - pub fn set_admins(conn: &PgConnection, org_id: i32, admin_ids: &[i32]) -> Option { - use crate::database::schema::organisation_users::dsl::*; - - diesel::update( - organisation_users - .filter(organisation_id.eq(org_id)) - .filter(user_id.eq_any(admin_ids)), - ) - .set(admin_level.eq(AdminLevel::Admin)) - .execute(conn) - .ok() - } - - pub fn get_logo(conn: &PgConnection, org_id: i32) -> Option { - use crate::database::schema::organisations::dsl::*; - - organisations - .filter(id.eq(org_id)) - .select(logo) - .first(conn) - .unwrap() - } - - pub fn set_logo(conn: &PgConnection, org_id: i32, new_logo: &str) -> String { - use crate::database::schema::organisations::dsl::*; - - diesel::update(organisations.find(org_id)) - .set(logo.eq(new_logo)) - .get_result::(conn) - .unwrap() - .logo - .unwrap() - } -} - -impl NewOrganisation { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::organisations::dsl::*; - - self.insert_into(organisations).get_result(conn).ok() - } -} - -#[derive(Queryable, Associations)] -#[belongs_to(Organisation)] -#[belongs_to(User)] -pub struct OrganisationUser { - pub id: i32, - pub user_id: i32, - pub organisation_id: i32, - pub admin_level: AdminLevel, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable)] -#[table_name = "organisation_users"] -pub struct NewOrganisationUser { - pub user_id: i32, - pub organisation_id: i32, - pub admin_level: AdminLevel, -} - -impl OrganisationUser { - pub fn get( - conn: &PgConnection, - organisation_id_val: i32, - user_id_val: i32, - ) -> Option { - use crate::database::schema::organisation_users::dsl::*; - - organisation_users - .filter(organisation_id.eq(organisation_id_val)) - .filter(user_id.eq(user_id_val)) - .first(conn) - .ok() - } - - pub fn update_admin_level( - &self, - conn: &PgConnection, - admin_level_val: AdminLevel, - ) -> Option<()> { - use crate::database::schema::organisation_users::dsl::*; - - diesel::update( - organisation_users - .filter(organisation_id.eq(self.organisation_id)) - .filter(user_id.eq(self.user_id)), - ) - .set(admin_level.eq(admin_level_val)) - .execute(conn) - .map(|_| ()) - .ok() - } - - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::organisation_users::dsl::*; - - organisation_users - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_user_id(conn: &PgConnection, user_id_val: i32) -> Vec { - use crate::database::schema::organisation_users::dsl::*; - - organisation_users - .filter(user_id.eq(user_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_organisation_id( - conn: &PgConnection, - organisation_id_val: i32, - ) -> Vec { - use crate::database::schema::organisation_users::dsl::*; - - organisation_users - .filter(organisation_id.eq(organisation_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } -} - -impl NewOrganisationUser { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::organisation_users::dsl::*; - - self.insert_into(organisation_users).get_result(conn).ok() - } -} - -#[derive(Queryable, Serialize, Debug, Associations)] -#[belongs_to(Organisation)] -pub struct Campaign { - pub id: i32, - pub organisation_id: i32, - pub name: String, - pub cover_image: Option, - pub description: String, - pub starts_at: NaiveDateTime, - pub ends_at: NaiveDateTime, - pub published: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(FromForm, Deserialize)] -pub struct UpdateCampaignInput { - pub name: String, - pub cover_image: Option, - pub description: String, - pub starts_at: String, - pub ends_at: String, - pub published: bool, -} - -#[derive(AsChangeset)] -#[table_name = "campaigns"] -pub struct UpdateCampaignChangeset { - pub name: String, - pub cover_image: Option, - pub description: String, - pub starts_at: NaiveDateTime, - pub ends_at: NaiveDateTime, - pub published: bool, -} - -#[derive(Insertable, Debug)] -#[table_name = "campaigns"] -pub struct NewCampaign { - pub organisation_id: i32, - pub name: String, - pub cover_image: Option, - pub description: String, - pub starts_at: NaiveDateTime, - pub ends_at: NaiveDateTime, - pub published: bool, -} - -#[derive(Deserialize, Clone, Debug)] -pub struct NewCampaignInput { - pub organisation_id: i32, - pub name: String, - pub description: String, - pub starts_at: String, - pub ends_at: String, - pub published: bool, -} - -#[derive(Serialize)] -pub struct CampaignWithRoles { - pub campaign: Campaign, - pub roles: Vec, - pub questions: Vec, - pub applied_for: Vec<(i32, ApplicationStatus)>, -} - -impl CampaignWithRoles { - pub fn with_http_cover_image(mut self) -> Self { - self.campaign = self.campaign.with_http_cover_image(); - self - } -} - -impl Campaign { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - campaigns - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_cover_image(conn: &PgConnection, campaign_id: i32) -> Option { - use crate::database::schema::campaigns::dsl::*; - - campaigns - .filter(id.eq(campaign_id)) - .select(cover_image) - .first(conn) - .unwrap() - } - - pub fn with_http_cover_image(mut self) -> Self { - self.cover_image = self - .cover_image - .map(|logo_uuid| get_http_image_path(ImageLocation::CAMPAIGNS, &logo_uuid)); - self - } - - pub fn set_cover_image(conn: &PgConnection, campaign_id: i32, new_cover_image: &str) -> String { - use crate::database::schema::campaigns::dsl::*; - - diesel::update(campaigns.find(campaign_id)) - .set(cover_image.eq(new_cover_image)) - .get_result::(conn) - .unwrap() - .cover_image - .unwrap() - } - - pub fn is_running(&self) -> bool { - let now = Utc::now().naive_utc(); - self.starts_at <= now && self.ends_at >= now - } - - /// return all campaigns that are live to all users - pub fn get_all_public_with_roles(conn: &PgConnection, user_id: i32) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - let now = Utc::now().naive_utc(); - let campaigns_vec: Vec = campaigns - .filter( - starts_at - .le(now) - .and(published.eq(true)) - .and(ends_at.gt(now)), - ) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]); - - Self::pack_roles_and_applied_to_into_campaigns_vec(conn, campaigns_vec, user_id) - } - - fn pack_roles_and_applied_to_into_campaigns_vec( - conn: &PgConnection, - campaigns_vec: Vec, - user_id: i32, - ) -> Vec { - campaigns_vec - .into_iter() - .map(|campaign| { - let campaign_roles = Role::get_all_from_campaign_id(&conn, campaign.id); - - let mut seen = HashSet::new(); - - let questions = campaign_roles - .iter() - .map(|x| Question::get_all_from_role_id(conn, x.id).into_iter()) - .flatten() - .filter(|x| { - if !seen.contains(&x.id) { - seen.insert(x.id); - true - } else { - false - } - }) - .collect(); - - let applied_for: Vec<(i32, ApplicationStatus)> = campaign_roles - .clone() - .into_iter() - .filter_map(|role| { - let app = Application::get_all_from_role_id(&conn, role.id) - .into_iter() - .filter(|app| app.user_id == user_id) - .next()?; - Some((role.id, app.status)) - }) - .collect(); - - CampaignWithRoles { - campaign, - roles: campaign_roles, - applied_for, - questions, - } - }) - .collect() - } - - // return all campaigns that are live and in the past - pub fn get_all_public_ended_with_roles( - conn: &PgConnection, - user_id: i32, - ) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - let now = Utc::now().naive_utc(); - let campaigns_vec: Vec = campaigns - .filter(ends_at.lt(now).and(published.eq(true))) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]); - - Self::pack_roles_and_applied_to_into_campaigns_vec(conn, campaigns_vec, user_id) - } - - pub fn get_all_from_org_id(conn: &PgConnection, organisation_id_val: i32) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - campaigns - .filter(organisation_id.eq(organisation_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_from_organisation_id( - conn: &PgConnection, - organisation_id_val: i32, - ) -> Vec { - use crate::database::schema::campaigns::dsl::*; - - campaigns - .filter(organisation_id.eq(organisation_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_from_id(conn: &PgConnection, campaign_id: i32) -> Option { - use crate::database::schema::campaigns::dsl::*; - - campaigns.filter(id.eq(campaign_id)).first(conn).ok() - } - - pub fn update( - conn: &PgConnection, - campaign_id: i32, - update_campaign: &UpdateCampaignInput, - ) -> Option { - use crate::database::schema::campaigns::dsl::*; - - let update_changeset = UpdateCampaignChangeset { - name: update_campaign.name.clone(), - cover_image: update_campaign.cover_image.clone(), - description: update_campaign.description.clone(), - starts_at: NaiveDateTime::parse_from_str( - &update_campaign.starts_at, - "%Y-%m-%dT%H:%M:%S", - ) - .unwrap(), - ends_at: NaiveDateTime::parse_from_str(&update_campaign.ends_at, "%Y-%m-%dT%H:%M:%S") - .unwrap(), - published: update_campaign.published, - }; - - diesel::update(campaigns.filter(id.eq(campaign_id))) - .set(update_changeset) - .get_result(conn) - .ok() - } - - pub fn create(conn: &PgConnection, new_campaign: &NewCampaignInput) -> Option { - use crate::database::schema::campaigns::dsl::*; - - let new_campaign = NewCampaign { - organisation_id: new_campaign.organisation_id, - name: new_campaign.name.clone(), - cover_image: None, - description: new_campaign.description.clone(), - starts_at: NaiveDateTime::parse_from_str(&new_campaign.starts_at, "%Y-%m-%d %H:%M:%S") - .ok()?, - ends_at: NaiveDateTime::parse_from_str(&new_campaign.ends_at, "%Y-%m-%d %H:%M:%S") - .ok()?, - published: new_campaign.published, - }; - - if campaigns - .filter(organisation_id.eq(new_campaign.organisation_id)) - .filter(name.eq(&new_campaign.name)) - .first::(conn) - .is_ok() - { - return None; - } - - new_campaign.insert(conn) - } - - pub fn delete(conn: &PgConnection, campaign_id: i32) -> bool { - use crate::database::schema::campaigns::dsl::*; - - diesel::delete(campaigns.filter(id.eq(campaign_id))) - .execute(conn) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, campaign_id: i32) -> Option<()> { - use crate::database::schema::roles::dsl::{campaign_id as dsl_role_campaign_id, roles}; - - if let Some(cover_image) = Campaign::get_cover_image(conn, campaign_id) { - remove_file(Path::new(&cover_image)).ok()?; - } - - let role_items: Vec = roles - .filter(dsl_role_campaign_id.eq(campaign_id)) - .load(conn) - .map_err(|x| eprintln!("error in delete deep: {x:?}")) - .ok()?; - - role_items.into_iter().for_each(|role| { - Role::delete_deep(conn, role.id); - }); - - if !Campaign::delete(conn, campaign_id) { - None - } else { - Some(()) - } - } -} - -impl NewCampaign { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::campaigns::dsl::*; - self.insert_into(campaigns).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, Serialize, Associations, Clone, PartialEq)] -#[belongs_to(Campaign)] -pub struct Role { - pub id: i32, - pub campaign_id: i32, - pub name: String, - pub description: Option, - pub min_available: i32, - pub max_available: i32, - pub finalised: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable, AsChangeset, FromForm, Deserialize, Debug)] -#[table_name = "roles"] -pub struct RoleUpdate { - pub campaign_id: i32, - pub name: String, - pub description: Option, - pub min_available: i32, - pub max_available: i32, - pub finalised: bool, -} - -impl Role { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::roles::dsl::*; - - roles.order(id.asc()).load(conn).unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_campaign_id(conn: &PgConnection, campaign_id_val: i32) -> Vec { - use crate::database::schema::roles::dsl::*; - - roles - .filter(campaign_id.eq(campaign_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_from_name(conn: &PgConnection, role_name: &str) -> Option { - use crate::database::schema::roles::dsl::*; - - roles.filter(name.eq(role_name)).first(conn).ok() - } - - pub fn get_from_id(conn: &PgConnection, role_id: i32) -> Option { - use crate::database::schema::roles::dsl::*; - - roles.filter(id.eq(role_id)).first(conn).ok() - } - - pub fn update(conn: &PgConnection, role_id: i32, role_update: &RoleUpdate) -> Option { - use crate::database::schema::roles::dsl::*; - - diesel::update(roles.filter(id.eq(role_id))) - .set(role_update) - .get_result(conn) - .ok() - } - - pub fn delete(conn: &PgConnection, role_id: i32) -> bool { - use crate::database::schema::roles::dsl::*; - - diesel::delete(roles.filter(id.eq(role_id))) - .execute(conn) - .is_ok() - } - - pub fn delete_children(conn: &PgConnection, role: Role) -> Option<()> { - use diesel::pg::expression::dsl::any; - - let question_items: Vec = questions::table - .filter(any(questions::role_ids).eq(role.id)) - .load(conn) - .map_err(|x| { - eprintln!("error in delete_children: {x:?}"); - x - }) - .ok()?; - - diesel::delete(questions::table.filter(any(questions::role_ids).eq(role.id))) - .execute(conn) - .map_err(|x| { - eprintln!("error in delete_children: {x:?}"); - x - }) - .ok(); - - for question in question_items { - diesel::delete(answers::table.filter(answers::question_id.eq(question.id))) - .execute(conn) - .map_err(|x| { - eprintln!("error in delete_children: {x:?}"); - x - }) - .ok(); - } - - let application_items: Vec = applications::table - .filter(applications::role_id.eq(role.id)) - .load(conn) - .map_err(|x| { - eprintln!("error in delete_children: {x:?}"); - x - }) - .ok()?; - - for application in application_items { - Application::delete_deep(conn, application); - } - - diesel::delete(applications::table.filter(applications::role_id.eq(role.id))) - .execute(conn) - .map_err(|x| { - eprintln!("error in delete_children: {x:?}"); - x - }) - .ok(); - - Some(()) - } - - pub fn delete_deep(conn: &PgConnection, role_id: i32) -> Option<()> { - let questions = Question::get_all_from_role_id(conn, role_id); - let applications = Application::get_all_from_role_id(conn, role_id); - - questions.into_iter().for_each(|question| { - Question::delete_deep(conn, question.id); - }); - applications.into_iter().for_each(|application| { - Application::delete_deep(conn, application); - }); - - if Role::delete(conn, role_id) { - Some(()) - } else { - None - } - } -} - -impl RoleUpdate { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::roles::dsl::*; - - self.insert_into(roles).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, Associations, PartialEq, Serialize)] -#[belongs_to(Role)] -#[belongs_to(OrganisationUser, foreign_key = "user_id")] -pub struct Application { - pub id: i32, - pub user_id: i32, - pub role_id: i32, - pub status: ApplicationStatus, - pub private_status: ApplicationStatus, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable, FromForm, Deserialize)] -#[table_name = "applications"] -pub struct NewApplication { - pub user_id: i32, - pub role_id: i32, - pub status: ApplicationStatus, - pub private_status: ApplicationStatus, -} - -impl Application { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::applications::dsl::*; - - applications - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get(app_id: i32, conn: &PgConnection) -> Option { - use crate::database::schema::applications::dsl::*; - - applications.filter(id.eq(app_id)).first(conn).ok() - } - - pub fn get_all_from_user_id(conn: &PgConnection, user_id_val: i32) -> Vec { - use crate::database::schema::applications::dsl::*; - - applications - .filter(user_id.eq(user_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_role_id(conn: &PgConnection, role_id_val: i32) -> Vec { - use crate::database::schema::applications::dsl::*; - - applications - .filter(role_id.eq(role_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_campaign_id(conn: &PgConnection, role_id_val: i32) -> Vec { - use crate::database::schema::applications::dsl::*; - - applications - .filter(role_id.eq(role_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn delete(conn: &PgConnection, application_id: i32) -> bool { - use crate::database::schema::applications::dsl::*; - - diesel::delete(applications.filter(id.eq(application_id))) - .execute(conn) - .map_err(|x| { - eprintln!("error in delete application: {x:?}"); - x - }) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, application: Application) -> Option<()> { - let ratings = Rating::get_all_from_application_id(conn, application.id); - let comments = Comment::get_all_from_application_id(conn, application.id); - let answers = Answer::get_all_from_application_id(conn, application.id); - - ratings.into_iter().for_each(|rating| { - Rating::delete_deep(conn, rating.id); - }); - comments.into_iter().for_each(|comment| { - Comment::delete_deep(conn, comment.id); - }); - answers.into_iter().for_each(|answer| { - Answer::delete_deep(conn, answer.id); - }); - - match Application::delete(conn, application.id) { - true => Some(()), - false => None, - } - } -} - -impl NewApplication { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::applications::dsl::*; - - self.insert_into(applications).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, PartialEq, Serialize, Debug, QueryableByName)] -#[table_name = "questions"] -pub struct Question { - pub id: i32, - pub role_ids: Vec, - pub title: String, - pub description: Option, - pub max_bytes: i32, - pub required: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable, Serialize, Deserialize)] -#[table_name = "questions"] -pub struct NewQuestion { - pub role_ids: Vec, - pub title: String, - pub description: Option, - #[serde(default)] - pub max_bytes: i32, - pub required: bool, -} - -#[derive(Serialize)] -pub struct QuestionResponse { - pub id: i32, - pub role_ids: Vec, - pub title: String, - pub description: Option, - pub max_bytes: i32, - pub required: bool, -} - -impl std::convert::From for QuestionResponse { - fn from(question: Question) -> Self { - Self { - id: question.id, - role_ids: question.role_ids, - title: question.title, - description: question.description, - max_bytes: question.max_bytes, - required: question.required, - } - } -} - -#[derive(FromForm, AsChangeset, Deserialize)] -#[table_name = "questions"] -pub struct UpdateQuestionInput { - pub title: String, - pub description: Option, - pub max_bytes: i32, - pub required: bool, -} - -impl Question { - pub fn get_first_role(&self) -> i32 { - *self - .role_ids - .get(0) - .expect("Question should be for at least one role") - } - - pub fn update( - conn: &PgConnection, - question_id: i32, - update_question: UpdateQuestionInput, - ) -> Option<()> { - use crate::database::schema::questions::dsl::*; - - diesel::update(questions.filter(id.eq(question_id))) - .set(update_question) - .execute(conn) - .ok()?; - - Some(()) - } - - pub fn get_all_from_role_id(conn: &PgConnection, role_id_val: i32) -> Vec { - diesel::sql_query(&format!( - "select * from questions where {} = any(role_ids)", - role_id_val - )) - .load::(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn delete_all_from_role_id(conn: &PgConnection, role_id_val: i32) -> bool { - diesel::sql_query(&format!( - "delete from questions where {} = any(role_ids)", - role_id_val - )) - .execute(conn) - .is_ok() - } - - pub fn delete(conn: &PgConnection, question_id: i32) -> bool { - use crate::database::schema::questions::dsl::*; - - diesel::delete(questions.filter(id.eq(question_id))) - .execute(conn) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, question_id: i32) -> bool { - Answer::get_all_from_question_id(conn, question_id) - .into_iter() - .for_each(|answer| { - Answer::delete_deep(conn, answer.id); - }); - - Question::delete(conn, question_id) - } - - pub fn get_from_id(conn: &PgConnection, question_id: i32) -> Option { - use crate::database::schema::questions::dsl::*; - - questions.filter(id.eq(question_id)).first(conn).ok() - } -} - -impl NewQuestion { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::questions::dsl::*; - - self.insert_into(questions).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, Associations, PartialEq, Serialize)] -#[belongs_to(Question)] -#[belongs_to(Application)] -pub struct Answer { - pub id: i32, - pub application_id: i32, - pub question_id: i32, - pub description: String, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable, Deserialize)] -#[table_name = "answers"] -pub struct NewAnswer { - pub application_id: i32, - pub question_id: i32, - pub description: String, -} - -impl Answer { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::answers::dsl::*; - - answers - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_application_id( - conn: &PgConnection, - application_id_val: i32, - ) -> Vec { - use crate::database::schema::answers::dsl::*; - - answers - .filter(application_id.eq(application_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_question_id(conn: &PgConnection, question_id_val: i32) -> Vec { - use crate::database::schema::answers::dsl::*; - - answers - .filter(question_id.eq(question_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn delete(conn: &PgConnection, answer_id_val: i32) -> bool { - use crate::database::schema::answers::dsl::*; - - diesel::delete(answers.filter(id.eq(answer_id_val))) - .execute(conn) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, answer_id_val: i32) -> bool { - use crate::database::schema::answers::dsl::*; - - diesel::delete(answers.filter(id.eq(answer_id_val))) - .execute(conn) - .is_ok() - } -} - -impl NewAnswer { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::answers::dsl::*; - - self.insert_into(answers).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, Associations, PartialEq, Serialize)] -#[belongs_to(Application)] -#[belongs_to(OrganisationUser, foreign_key = "commenter_user_id")] -pub struct Comment { - pub id: i32, - pub application_id: i32, - pub commenter_user_id: i32, - pub description: String, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable)] -#[table_name = "comments"] -pub struct NewComment { - pub application_id: i32, - pub commenter_user_id: i32, - pub description: String, -} - -impl Comment { - pub fn get_from_id(conn: &PgConnection, comment_id: i32) -> Option { - use crate::database::schema::comments::dsl::*; - - comments.filter(id.eq(comment_id)).first(conn).ok() - } - - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::comments::dsl::*; - - comments - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_application_id( - conn: &PgConnection, - application_id_val: i32, - ) -> Vec { - use crate::database::schema::comments::dsl::*; - - comments - .filter(application_id.eq(application_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn delete(conn: &PgConnection, comment_id_val: i32) -> bool { - use crate::database::schema::comments::dsl::*; - - diesel::delete(comments.filter(id.eq(comment_id_val))) - .execute(conn) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, comment_id_val: i32) -> bool { - use crate::database::schema::comments::dsl::*; - - diesel::delete(comments.filter(id.eq(comment_id_val))) - .execute(conn) - .is_ok() - } -} - -impl NewComment { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::comments::dsl::*; - - self.insert_into(comments).get_result(conn).ok() - } -} - -#[derive(Identifiable, Queryable, Associations, PartialEq, Serialize)] -#[belongs_to(Application)] -#[belongs_to(OrganisationUser, foreign_key = "rater_user_id")] -pub struct Rating { - pub id: i32, - pub application_id: i32, - pub rater_user_id: i32, - pub rating: i32, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Insertable)] -#[table_name = "ratings"] -pub struct NewRating { - pub application_id: i32, - pub rater_user_id: i32, - pub rating: i32, -} - -impl Rating { - pub fn get_all(conn: &PgConnection) -> Vec { - use crate::database::schema::ratings::dsl::*; - - ratings - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_application_id( - conn: &PgConnection, - application_id_val: i32, - ) -> Vec { - use crate::database::schema::ratings::dsl::*; - - ratings - .filter(application_id.eq(application_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn get_all_from_rater_user_id(conn: &PgConnection, user_id_val: i32) -> Vec { - use crate::database::schema::ratings::dsl::*; - - ratings - .filter(rater_user_id.eq(user_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - } - - pub fn delete(conn: &PgConnection, rating_id_val: i32) -> bool { - use crate::database::schema::ratings::dsl::*; - diesel::delete(ratings.filter(id.eq(rating_id_val))) - .execute(conn) - .is_ok() - } - - pub fn delete_deep(conn: &PgConnection, rating_id_val: i32) -> bool { - use crate::database::schema::ratings::dsl::*; - diesel::delete(ratings.filter(id.eq(rating_id_val))) - .execute(conn) - .is_ok() - } -} - -impl NewRating { - pub fn insert(&self, conn: &PgConnection) -> Option { - use crate::database::schema::ratings::dsl::*; - - self.insert_into(ratings).get_result(conn).ok() - } -} - -#[derive(Serialize)] -pub struct GetQuestionsResponse { - pub questions: Vec, -} - -#[derive(Serialize)] -pub struct CampaignInfo { - pub id: i32, - pub name: String, - pub cover_image: Option, - pub starts_at: NaiveDateTime, - pub ends_at: NaiveDateTime, -} - -impl std::convert::From for CampaignInfo { - fn from(campaign: Campaign) -> Self { - Self { - id: campaign.id, - name: campaign.name, - cover_image: campaign - .cover_image - .map(|image| get_http_image_path(ImageLocation::CAMPAIGNS, &image)), - starts_at: campaign.starts_at, - ends_at: campaign.ends_at, - } - } -} - -#[derive(Serialize)] -pub struct OrganisationUserInfo { - pub id: i32, - pub display_name: String, - pub role: AdminLevel, -} - -impl OrganisationUserInfo { - pub fn get_all_from_organisation_id( - conn: &PgConnection, - organisation_id_val: i32, - ) -> Vec { - use crate::database::schema::organisation_users::dsl::*; - - organisation_users - .filter(organisation_id.eq(organisation_id_val)) - .order(id.asc()) - .load(conn) - .unwrap_or_else(|_| vec![]) - .into_iter() - .map(|o: OrganisationUser| { - let user = User::get_from_id(conn, o.user_id).unwrap(); - Self { - id: o.user_id, - display_name: user.display_name, - role: o.admin_level, - } - }) - .collect() - } -} - -#[derive(Serialize)] -pub struct OrganisationInfo { - pub id: i32, - pub name: String, - pub logo: Option, - pub members: Vec, - pub campaigns: Vec, -} - -impl OrganisationInfo { - pub fn new(organisation_id: i32, conn: &PgConnection) -> Self { - let organisation = Organisation::get_from_id(conn, organisation_id).unwrap(); - Self { - id: organisation.id, - name: organisation.name, - logo: organisation - .logo - .map(|logo_uuid| get_http_image_path(ImageLocation::ORGANISATIONS, &logo_uuid)), - members: OrganisationUserInfo::get_all_from_organisation_id(conn, organisation.id), - campaigns: Campaign::get_all_from_org_id(conn, organisation.id) - .into_iter() - .map(|c: Campaign| CampaignInfo::from(c)) - .collect(), - } - } -} - -#[derive(Serialize)] -pub struct AdminInfoResponse { - pub organisations: Vec, -} diff --git a/backend/server/src/database/schema.rs b/backend/server/src/database/schema.rs deleted file mode 100644 index a5a526d4f..000000000 --- a/backend/server/src/database/schema.rs +++ /dev/null @@ -1,197 +0,0 @@ -use diesel_derive_enum::DbEnum; -use rocket::FromFormField; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, DbEnum, PartialEq, FromFormField, Serialize, Deserialize, Clone, Copy)] -#[DbValueStyle = "PascalCase"] -pub enum ApplicationStatus { - Draft, - Pending, - Rejected, - Success, -} - -#[derive(Debug, DbEnum, PartialEq, Serialize, Deserialize, Clone, Copy)] -#[DbValueStyle = "PascalCase"] -pub enum AdminLevel { - ReadOnly = 1, - Director, - Admin, -} - -impl AdminLevel { - pub fn geq(self, other: Self) -> bool { - self as i32 >= other as i32 - } -} - -#[derive(Debug, DbEnum, PartialEq, Serialize, Deserialize, Clone, Copy)] -#[DbValueStyle = "PascalCase"] -pub enum UserGender { - Female, - Male, - Unspecified, -} - -table! { - answers (id) { - id -> Int4, - application_id -> Int4, - question_id -> Int4, - description -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - use diesel::sql_types::*; - use super::ApplicationStatusMapping; - - applications (id) { - id -> Int4, - user_id -> Int4, - role_id -> Int4, - status -> ApplicationStatusMapping, - private_status -> ApplicationStatusMapping, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - campaigns (id) { - id -> Int4, - organisation_id -> Int4, - name -> Text, - cover_image -> Nullable, - description -> Text, - starts_at -> Timestamp, - ends_at -> Timestamp, - published -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - comments (id) { - id -> Int4, - application_id -> Int4, - commenter_user_id -> Int4, - description -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - use diesel::sql_types::*; - use super::AdminLevelMapping; - - organisation_users (id) { - id -> Int4, - user_id -> Int4, - organisation_id -> Int4, - admin_level -> AdminLevelMapping, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - organisations (id) { - id -> Int4, - name -> Text, - logo -> Nullable, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - use diesel::sql_types::*; - - questions (id) { - id -> Int4, - role_ids -> Array, - title -> Text, - description -> Nullable, - max_bytes -> Int4, - required -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - ratings (id) { - id -> Int4, - application_id -> Int4, - rater_user_id -> Int4, - rating -> Int4, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - roles (id) { - id -> Int4, - campaign_id -> Int4, - name -> Text, - description -> Nullable, - min_available -> Int4, - max_available -> Int4, - finalised -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -table! { - use diesel::sql_types::*; - use super::UserGenderMapping; - - users (id) { - id -> Int4, - email -> Text, - zid -> Text, - display_name -> Text, - degree_name -> Text, - degree_starting_year -> Int4, - gender -> UserGenderMapping, - pronouns -> Text, - superuser -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -joinable!(answers -> applications (application_id)); -joinable!(answers -> questions (question_id)); -joinable!(applications -> roles (role_id)); -joinable!(applications -> users (user_id)); -joinable!(campaigns -> organisations (organisation_id)); -joinable!(comments -> applications (application_id)); -joinable!(comments -> users (commenter_user_id)); -joinable!(organisation_users -> organisations (organisation_id)); -joinable!(organisation_users -> users (user_id)); -// TODO: can probably make this work to auto join but idk how -// joinable!(questions -> roles (role_id)); -joinable!(ratings -> applications (application_id)); -joinable!(ratings -> users (rater_user_id)); -joinable!(roles -> campaigns (campaign_id)); - -allow_tables_to_appear_in_same_query!( - answers, - applications, - campaigns, - comments, - organisation_users, - organisations, - questions, - ratings, - roles, - users, -); diff --git a/backend/server/src/diesel.toml b/backend/server/src/diesel.toml deleted file mode 100644 index bfb01bccf..000000000 --- a/backend/server/src/diesel.toml +++ /dev/null @@ -1,5 +0,0 @@ -# For documentation on how to configure this file, -# see diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/database/schema.rs" diff --git a/backend/server/src/error.rs b/backend/server/src/error.rs deleted file mode 100644 index b73a1cb17..000000000 --- a/backend/server/src/error.rs +++ /dev/null @@ -1,16 +0,0 @@ -use rocket::http::Status; -use rocket::request::Request; -use rocket::response::{self, Responder}; -use rocket::serde::{json::Json, Serialize}; - -pub struct JsonErr(pub T, pub Status); - -impl<'r, T: Serialize> Responder<'r, 'r> for JsonErr { - fn respond_to(self, r: &Request) -> response::Result<'r> { - let json_self = Json(self.0); - json_self.respond_to(r).map(|mut r| { - r.set_status(self.1); - r - }) - } -} diff --git a/backend/server/src/guard/mod.rs b/backend/server/src/guard/mod.rs deleted file mode 100644 index e97ade673..000000000 --- a/backend/server/src/guard/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -use rocket::{ - http::Status, - request::{self, FromRequest, Outcome}, - Request, -}; - -use crate::{ - auth::{Auth, AuthError}, - database::{ - models::{SuperUser, User}, - Database, - }, -}; - -#[derive(Debug)] -pub enum UserError { - AuthError(AuthError), - AccountNoLongerExists, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for User { - type Error = UserError; - - async fn from_request(request: &'r Request<'_>) -> request::Outcome { - let auth = match request.guard::().await { - Outcome::Success(auth) => auth, - Outcome::Failure((status, error)) => { - return Outcome::Failure((status, UserError::AuthError(error))) - } - Outcome::Forward(forward) => return Outcome::Forward(forward), - }; - - let database = request.guard::().await.unwrap(); - - let user_id = auth.jwt.user_id; - let user = database - .run(move |conn| User::get_from_id(conn, user_id as i32)) - .await; - - match user { - Some(user) => Outcome::Success(user), - None => Outcome::Failure(( - Status::InternalServerError, - UserError::AccountNoLongerExists, - )), - } - } -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for SuperUser { - type Error = UserError; - - async fn from_request(request: &'r Request<'_>) -> request::Outcome { - let auth = match request.guard::().await { - Outcome::Success(auth) => auth, - Outcome::Failure((status, error)) => { - return Outcome::Failure((status, UserError::AuthError(error))) - } - Outcome::Forward(forward) => return Outcome::Forward(forward), - }; - - let database = request.guard::().await.unwrap(); - - let user_id = auth.jwt.user_id; - let user = database - .run(move |conn| User::get_from_id(conn, user_id as i32)) - .await; - - match user { - Some(user) => { - if user.superuser { - Outcome::Success(SuperUser::new(user)) - } else { - Outcome::Failure(( - Status::Forbidden, - UserError::AuthError(AuthError::NotSuperUser), - )) - } - } - None => Outcome::Failure(( - Status::InternalServerError, - UserError::AccountNoLongerExists, - )), - } - } -} diff --git a/backend/server/src/handler/answer.rs b/backend/server/src/handler/answer.rs new file mode 100644 index 000000000..bfd0e8e8e --- /dev/null +++ b/backend/server/src/handler/answer.rs @@ -0,0 +1,174 @@ +//! Answer handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing application answers, including: +//! - Creating and retrieving answers +//! - Updating and deleting answers +//! - Managing role-specific answers + +use crate::models::answer::{Answer, NewAnswer}; +use crate::models::app::AppState; +use crate::models::auth::{AnswerOwner, ApplicationOwner}; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; +use crate::models::application::{OpenApplicationByAnswerId, OpenApplicationByApplicationId}; + +/// Handler for answer-related HTTP requests. +pub struct AnswerHandler; + +impl AnswerHandler { + /// Creates a new answer for an application. + /// + /// This handler allows application owners to create answers for their application. + /// The application must be open and not already submitted. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `application_id` - The ID of the application + /// * `_user` - The authenticated user (must be the application owner) + /// * `_` - Ensures the application is open + /// * `transaction` - Database transaction + /// * `data` - The answer details + /// + /// # Returns + /// + /// * `Result` - Created answer ID or error + pub async fn create( + State(mut state): State, + Path(application_id): Path, + _user: ApplicationOwner, + _: OpenApplicationByApplicationId, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + // TODO: Check whether the question is contained in the campaign being applied to + let id = Answer::create( + application_id, + data.question_id, + data.answer_data, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(json!({"id": id})))) + } + + /// Retrieves all common answers for an application. + /// + /// This handler allows application owners to view all common answers. + /// + /// # Arguments + /// + /// * `application_id` - The ID of the application + /// * `_owner` - The authenticated user (must be the application owner) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of answers or error + pub async fn get_all_common_by_application( + Path(application_id): Path, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = + Answer::get_all_common_by_application(application_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + /// Retrieves all answers for a specific role in an application. + /// + /// This handler allows application owners to view role-specific answers. + /// + /// # Arguments + /// + /// * `application_id` - The ID of the application + /// * `role_id` - The ID of the role + /// * `_owner` - The authenticated user (must be the application owner) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of answers or error + pub async fn get_all_by_application_and_role( + Path((application_id, role_id)): Path<(i64, i64)>, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = + Answer::get_all_by_application_and_role(application_id, role_id, &mut transaction.tx) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + /// Updates an existing answer. + /// + /// This handler allows answer owners to update their answers. + /// The application must be open and not already submitted. + /// + /// # Arguments + /// + /// * `answer_id` - The ID of the answer to update + /// * `_owner` - The authenticated user (must be the answer owner) + /// * `_` - Ensures the application is open + /// * `transaction` - Database transaction + /// * `data` - The new answer details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + Path(answer_id): Path, + _owner: AnswerOwner, + _: OpenApplicationByAnswerId, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Answer::update(answer_id, data.answer_data, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully updated answer")) + } + + /// Deletes an answer. + /// + /// This handler allows answer owners to delete their answers. + /// The application must be open and not already submitted. + /// + /// # Arguments + /// + /// * `answer_id` - The ID of the answer to delete + /// * `_owner` - The authenticated user (must be the answer owner) + /// * `_` - Ensures the application is open + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + Path(answer_id): Path, + _owner: AnswerOwner, + _: OpenApplicationByAnswerId, + mut transaction: DBTransaction<'_>, + ) -> Result { + Answer::delete(answer_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted answer")) + } +} diff --git a/backend/server/src/handler/application.rs b/backend/server/src/handler/application.rs new file mode 100644 index 000000000..72f01d8bd --- /dev/null +++ b/backend/server/src/handler/application.rs @@ -0,0 +1,228 @@ +//! Application handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing applications, including: +//! - Creating and retrieving applications +//! - Updating application status and roles +//! - Submitting applications +//! - Managing application ratings + +use crate::models::app::AppState; +use crate::models::application::{Application, ApplicationRoleUpdate, ApplicationStatus, OpenApplicationByApplicationId}; +use crate::models::auth::{ApplicationAdmin, ApplicationOwner, ApplicationReviewerGivenApplicationId, AuthUser}; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use crate::models::rating::{NewRating, Rating}; + +/// Handler for application-related HTTP requests. +pub struct ApplicationHandler; + +impl ApplicationHandler { + /// Retrieves the details of a specific application. + /// + /// This handler allows application admins to view application details. + /// + /// # Arguments + /// + /// * `application_id` - The ID of the application to retrieve + /// * `_admin` - The authenticated user (must be an application admin) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Application details or error + pub async fn get( + Path(application_id): Path, + _admin: ApplicationAdmin, + mut transaction: DBTransaction<'_>, + ) -> Result { + let application = Application::get(application_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(application))) + } + + /// Updates the status of an application. + /// + /// This handler allows application admins to update the application's status. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `application_id` - The ID of the application to update + /// * `_admin` - The authenticated user (must be an application admin) + /// * `data` - The new application status + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn set_status( + Path(application_id): Path, + _admin: ApplicationAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Application::set_status(application_id, data, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Status successfully updated")) + } + + /// Updates the private status of an application. + /// + /// This handler allows application admins to update the application's private status. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `application_id` - The ID of the application to update + /// * `_admin` - The authenticated user (must be an application admin) + /// * `data` - The new private status + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn set_private_status( + Path(application_id): Path, + _admin: ApplicationAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Application::set_private_status(application_id, data, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Private Status successfully updated")) + } + + /// Retrieves all applications for the current user. + /// + /// This handler returns all applications created by the authenticated user. + /// + /// # Arguments + /// + /// * `user` - The authenticated user + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of applications or error + pub async fn get_from_curr_user( + user: AuthUser, + mut transaction: DBTransaction<'_>, + ) -> Result { + let applications = Application::get_from_user_id(user.user_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(applications))) + } + + /// Updates the roles associated with an application. + /// + /// This handler allows application owners to update the roles they're applying for. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be the application owner) + /// * `application_id` - The ID of the application to update + /// * `transaction` - Database transaction + /// * `data` - The new role assignments + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_roles( + _user: ApplicationOwner, + Path(application_id): Path, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Application::update_roles(application_id, data.roles, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated application roles")) + } + + /// Submits an application for review. + /// + /// This handler allows application owners to submit their application. + /// The application must be open and not already submitted. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be the application owner) + /// * `_` - Ensures the application is open + /// * `application_id` - The ID of the application to submit + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn submit( + _user: ApplicationOwner, + _: OpenApplicationByApplicationId, + Path(application_id): Path, + mut transaction: DBTransaction<'_>, + ) -> Result { + Application::submit(application_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully submitted application")) + } + + /// Creates a new rating for an application. + /// + /// This handler allows application reviewers to create ratings. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `application_id` - The ID of the application to rate + /// * `admin` - The authenticated user (must be an application reviewer) + /// * `transaction` - Database transaction + /// * `new_rating` - The rating details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_rating( + State(mut state): State, + Path(application_id): Path, + admin: ApplicationReviewerGivenApplicationId, + mut transaction: DBTransaction<'_>, + Json(new_rating): Json, + ) -> Result { + Rating::create( + new_rating, + application_id, + admin.user_id, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created rating")) + } + + /// Retrieves all ratings for an application. + /// + /// This handler allows application reviewers to view all ratings for an application. + /// + /// # Arguments + /// + /// * `_state` - The application state + /// * `application_id` - The ID of the application + /// * `_admin` - The authenticated user (must be an application reviewer) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of ratings or error + pub async fn get_ratings( + State(_state): State, + Path(application_id): Path, + _admin: ApplicationReviewerGivenApplicationId, + mut transaction: DBTransaction<'_>, + ) -> Result { + let ratings = + Rating::get_all_ratings_from_application_id(application_id, &mut transaction.tx) + .await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(ratings))) + } +} diff --git a/backend/server/src/handler/auth.rs b/backend/server/src/handler/auth.rs new file mode 100644 index 000000000..2e561fc68 --- /dev/null +++ b/backend/server/src/handler/auth.rs @@ -0,0 +1,223 @@ +//! Authentication handler for the Chaos application. +//! +//! This module provides HTTP request handlers for authentication, including: +//! - Google OAuth2 authentication +//! - JWT token generation + +use crate::models::app::AppState; +use crate::models::auth::{AuthRequest, GoogleUserProfile}; +use crate::models::error::ChaosError; +use crate::service::auth::create_or_get_user_id; +use crate::service::jwt::encode_auth_token; +use axum::extract::{Query, State}; +use axum_extra::extract::cookie::{Cookie, CookieJar, Expiration}; +use axum::response::{IntoResponse, Redirect}; +use oauth2::reqwest::async_http_client; +use oauth2::{AuthorizationCode, TokenResponse, Scope}; +use time::OffsetDateTime; + +/// Handles the Google OAuth2 callback. +/// +/// This handler processes the OAuth2 code received from Google after user authorization. +/// It exchanges the code for an access token, retrieves the user's profile information, +/// creates or retrieves the user in the database, and generates a JWT token for authentication. +/// +/// # Arguments +/// +/// * `state` - The application state +/// * `query` - The OAuth2 callback query parameters containing the authorization code +/// * `oauth_client` - The OAuth2 client for Google authentication +/// +/// # Returns +/// +/// * `Result` - JWT token or error +/// +/// Initiates the Google OAuth2 flow. +/// +/// This handler redirects users to Google's OAuth2 authorization URL to begin +/// the authentication process. +/// +/// # Arguments +/// +/// * `state` - The application state containing the OAuth2 client +/// +/// # Returns +/// +/// * `Result` - Redirect to Google OAuth or error +pub async fn google_auth_init( + State(state): State, +) -> Result { + let (auth_url, _csrf_token) = state.oauth2_client + .authorize_url(|| oauth2::CsrfToken::new_random()) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + Ok(Redirect::to(auth_url.as_str())) +} + +/// Handles the Google OAuth2 callback. +/// +/// This handler processes the OAuth2 code received from Google after user authorization. +/// It exchanges the code for an access token, retrieves the user's profile information, +/// creates or retrieves the user in the database, and generates a JWT token for authentication. +/// +/// # Arguments +/// +/// * `state` - The application state +/// * `query` - The OAuth2 callback query parameters containing the authorization code +/// * `oauth_client` - The OAuth2 client for Google authentication +/// +/// # Returns +/// +/// * `Result` - JWT token or error +/// +/// # Note +/// +/// Currently returns the JWT token directly. TODO: Return it as a set-cookie header. +pub async fn google_callback( + State(mut state): State, + jar: CookieJar, + Query(query): Query, +) -> Result { + let token = state.oauth2_client + .exchange_code(AuthorizationCode::new(query.code)) + .request_async(async_http_client) + .await?; + + let profile = state + .ctx + .get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(token.access_token().secret().to_owned()) + .send() + .await?; + + let profile = profile.json::().await?; + + let user_id = create_or_get_user_id( + profile.email.clone(), + profile.name, + state.db, + &mut state.snowflake_generator, + ) + .await?; + + let token = encode_auth_token( + profile.email, + user_id, + &state.encoding_key, + &state.jwt_header, + ); + + // Create a cookie with the token + let cookie = Cookie::build(("auth_token", token)) + .http_only(true) // Prevent JavaScript access + .expires(Expiration::DateTime(OffsetDateTime::now_utc() + time::Duration::days(5))) // Set an expiration time of 5 days, TODO: read from env? + .secure(!state.is_dev_env) // Send only over HTTPS, comment out for testing + .path("/"); // Available for all paths + + // Redirect to the frontend dashboard after successful authentication + let redirect_url = if state.is_dev_env { + "http://localhost:3000/dashboard" + } else { + "/dashboard" // In production, this would be the full URL + }; + + // Add the cookie and redirect + Ok((jar.add(cookie), Redirect::to(redirect_url))) +} + +pub struct DevLoginHandler; +impl DevLoginHandler { + + pub async fn dev_super_admin_login( + State(state): State, + jar: CookieJar + ) -> Result { + + if !state.is_dev_env { + // Disabled for non dev environment + return Err(ChaosError::ForbiddenOperation); + } + + let token = encode_auth_token( + "example.superuser@chaos.devsoc.app".to_string(), + 1, + &state.encoding_key, + &state.jwt_header, + ); + + // Create a cookie with the token + let cookie = Cookie::build(("auth_token", token)) + .http_only(true) // Prevent JavaScript access + .expires(Expiration::DateTime(OffsetDateTime::now_utc() + time::Duration::days(5))) // Set an expiration time of 5 days, TODO: read from env? + .path("/"); // Available for all paths + + // Redirect to the frontend dashboard after successful authentication + let redirect_url = "http://localhost:3000/dashboard"; + + // Add the cookie and redirect + Ok((jar.add(cookie), Redirect::to(redirect_url))) + } + + pub async fn dev_org_admin_login( + State(state): State, + jar: CookieJar + ) -> Result { + + if !state.is_dev_env { + // Disabled for non dev environment + return Err(ChaosError::ForbiddenOperation); + } + + let token = encode_auth_token( + "example.admin@chaos.devsoc.app".to_string(), + 2, + &state.encoding_key, + &state.jwt_header, + ); + + // Create a cookie with the token + let cookie = Cookie::build(("auth_token", token)) + .http_only(true) // Prevent JavaScript access + .expires(Expiration::DateTime(OffsetDateTime::now_utc() + time::Duration::days(5))) // Set an expiration time of 5 days, TODO: read from env? + .path("/"); // Available for all paths + + // Redirect to the frontend dashboard after successful authentication + let redirect_url = "http://localhost:3000/dashboard"; + + // Add the cookie and redirect + Ok((jar.add(cookie), Redirect::to(redirect_url))) + } + + pub async fn dev_user_login( + State(state): State, + jar: CookieJar + ) -> Result { + + if !state.is_dev_env { + // Disabled for non dev environment + return Err(ChaosError::ForbiddenOperation); + } + + let token = encode_auth_token( + "example.user@chaos.devsoc.app".to_string(), + 3, + &state.encoding_key, + &state.jwt_header, + ); + + // Create a cookie with the token + let cookie = Cookie::build(("auth_token", token)) + .http_only(true) // Prevent JavaScript access + .expires(Expiration::DateTime(OffsetDateTime::now_utc() + time::Duration::days(5))) // Set an expiration time of 5 days, TODO: read from env? + .path("/"); // Available for all paths + + // Redirect to the frontend dashboard after successful authentication + let redirect_url = "http://localhost:3000/dashboard"; + + // Add the cookie and redirect + Ok((jar.add(cookie), Redirect::to(redirect_url))) + } +} \ No newline at end of file diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs new file mode 100644 index 000000000..a707d1842 --- /dev/null +++ b/backend/server/src/handler/campaign.rs @@ -0,0 +1,341 @@ +//! Campaign handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing campaigns, including: +//! - Campaign CRUD operations +//! - Role management within campaigns +//! - Application management +//! - Offer management +//! - Banner image handling + +use crate::models; +use crate::models::app::AppState; +use crate::models::application::Application; +use crate::models::application::NewApplication; +use crate::models::auth::AuthUser; +use crate::models::auth::CampaignAdmin; +use crate::models::campaign::{Campaign, OpenCampaign}; +use crate::models::error::ChaosError; +use crate::models::offer::Offer; +use crate::models::role::{Role, RoleUpdate}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for campaign-related HTTP requests. +pub struct CampaignHandler; + +impl CampaignHandler { + /// Retrieves a campaign by its ID. + /// + /// This handler allows any authenticated user to view campaign details. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the campaign to retrieve + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - Campaign details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let campaign = Campaign::get(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(campaign))) + } + + /// Retrieves a campaign by its organisation and campaign slugs. + /// + /// This handler allows any authenticated user to view campaign details using slugs. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `organisation_slug` - The slug of the organisation + /// * `campaign_slug` - The slug of the campaign + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - Campaign details or error + pub async fn get_by_slugs( + mut transaction: DBTransaction<'_>, + Path((organisation_slug, campaign_slug)): Path<(String, String)>, + _user: AuthUser, + ) -> Result { + let campaign = + Campaign::get_by_slugs(organisation_slug, campaign_slug, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(campaign))) + } + + /// Retrieves all campaigns. + /// + /// This handler allows any authenticated user to view all campaigns. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - List of campaigns or error + pub async fn get_all( + mut transaction: DBTransaction<'_>, + _user: AuthUser, + ) -> Result { + let campaigns = Campaign::get_all(&mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(campaigns))) + } + + /// Updates a campaign. + /// + /// This handler allows campaign admins to update campaign details. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the campaign to update + /// * `_admin` - The authenticated user (must be a campaign admin) + /// * `request_body` - The new campaign details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: CampaignAdmin, + Json(request_body): Json, + ) -> Result { + Campaign::update(id, request_body, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated campaign")) + } + + /// Updates a campaign's banner image. + /// + /// This handler allows campaign admins to update the campaign's banner image. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `state` - The application state + /// * `id` - The ID of the campaign + /// * `_admin` - The authenticated user (must be a campaign admin) + /// + /// # Returns + /// + /// * `Result` - Banner URL or error + pub async fn update_banner( + mut transaction: DBTransaction<'_>, + State(state): State, + Path(id): Path, + _admin: CampaignAdmin, + ) -> Result { + let banner_url = + Campaign::update_banner(id, &mut transaction.tx, &state.storage_bucket).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(banner_url))) + } + + /// Deletes a campaign. + /// + /// This handler allows campaign admins to delete campaigns. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the campaign to delete + /// * `_admin` - The authenticated user (must be a campaign admin) + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: CampaignAdmin, + ) -> Result { + Campaign::delete(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully deleted campaign")) + } + + /// Creates a new role in a campaign. + /// + /// This handler allows campaign admins to create new roles. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `state` - The application state + /// * `id` - The ID of the campaign + /// * `_admin` - The authenticated user (must be a campaign admin) + /// * `data` - The new role details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_role( + mut transaction: DBTransaction<'_>, + State(mut state): State, + Path(id): Path, + _admin: CampaignAdmin, + Json(data): Json, + ) -> Result { + Role::create(id, data, &mut transaction.tx, &mut state.snowflake_generator).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created role")) + } + + /// Retrieves all roles in a campaign. + /// + /// This handler allows any authenticated user to view all roles in a campaign. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the campaign + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - List of roles or error + pub async fn get_roles( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let roles = Role::get_all_in_campaign(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(roles))) + } + + /// Creates a new application for a campaign. + /// + /// This handler allows authenticated users to apply to open campaigns. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the campaign + /// * `user` - The authenticated user + /// * `_` - Ensures the campaign is open + /// * `transaction` - Database transaction + /// * `data` - The new application details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_application( + State(mut state): State, + Path(id): Path, + user: AuthUser, + _: OpenCampaign, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Application::create( + id, + user.user_id, + data, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created application")) + } + + /// Retrieves all applications for a campaign. + /// + /// This handler allows campaign admins to view all applications. + /// + /// # Arguments + /// + /// * `id` - The ID of the campaign + /// * `_admin` - The authenticated user (must be a campaign admin) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of applications or error + pub async fn get_applications( + Path(id): Path, + _admin: CampaignAdmin, + mut transaction: DBTransaction<'_>, + ) -> Result { + let applications = Application::get_from_campaign_id(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(applications))) + } + + /// Creates a new offer for an application. + /// + /// This handler allows campaign admins to create offers for applications. + /// + /// # Arguments + /// + /// * `id` - The ID of the campaign + /// * `state` - The application state + /// * `_admin` - The authenticated user (must be a campaign admin) + /// * `transaction` - Database transaction + /// * `data` - The new offer details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_offer( + Path(id): Path, + State(mut state): State, + _admin: CampaignAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let _ = Offer::create( + id, + data.application_id, + data.email_template_id, + data.role_id, + data.expiry, + &mut transaction.tx, + &mut state.snowflake_generator, + ) + .await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully created offer")) + } + + /// Retrieves all offers for a campaign. + /// + /// This handler allows campaign admins to view all offers. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the campaign + /// * `_user` - The authenticated user (must be a campaign admin) + /// + /// # Returns + /// + /// * `Result` - List of offers or error + pub async fn get_offers( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: CampaignAdmin, + ) -> Result { + let offers = Offer::get_by_campaign(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(offers))) + } +} diff --git a/backend/server/src/handler/email_template.rs b/backend/server/src/handler/email_template.rs new file mode 100644 index 000000000..011efcd3d --- /dev/null +++ b/backend/server/src/handler/email_template.rs @@ -0,0 +1,100 @@ +//! Email template handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing email templates, including: +//! - Retrieving template details +//! - Updating templates +//! - Deleting templates + +use crate::models::app::AppState; +use crate::models::auth::EmailTemplateAdmin; +use crate::models::email_template::EmailTemplate; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for email template-related HTTP requests. +pub struct EmailTemplateHandler; + +impl EmailTemplateHandler { + /// Retrieves the details of a specific email template. + /// + /// This handler allows email template admins to view template details. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the template to retrieve + /// * `_user` - The authenticated user (must be an email template admin) + /// + /// # Returns + /// + /// * `Result` - Template details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: EmailTemplateAdmin, + ) -> Result { + let email_template = EmailTemplate::get(id, &mut transaction.tx).await?; + + Ok((StatusCode::OK, Json(email_template))) + } + + /// Updates an email template. + /// + /// This handler allows email template admins to update template details. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be an email template admin) + /// * `id` - The ID of the template to update + /// * `state` - The application state + /// * `request_body` - The new template details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + _user: EmailTemplateAdmin, + Path(id): Path, + mut transaction: DBTransaction<'_>, + Json(request_body): Json, + ) -> Result { + EmailTemplate::update( + id, + request_body.name, + request_body.template_subject, + request_body.template_body, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated email template")) + } + + /// Deletes an email template. + /// + /// This handler allows email template admins to delete templates. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be an email template admin) + /// * `id` - The ID of the template to delete + /// * `state` - The application state + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + _user: EmailTemplateAdmin, + Path(id): Path, + mut transaction: DBTransaction<'_>, + ) -> Result { + EmailTemplate::delete(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully delete email template")) + } +} diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs new file mode 100644 index 000000000..effc73011 --- /dev/null +++ b/backend/server/src/handler/mod.rs @@ -0,0 +1,29 @@ +//! HTTP request handlers for the Chaos application. +//! +//! This module contains the HTTP request handlers that process incoming requests and return responses. +//! Each submodule corresponds to a specific domain of the application and contains handlers for +//! related endpoints: +//! +//! - `answer`: Handles requests related to application answers +//! - `application`: Processes application-related requests +//! - `auth`: Manages authentication and authorization requests +//! - `campaign`: Handles campaign-related requests +//! - `email_template`: Processes email template requests +//! - `offer`: Handles offer-related requests +//! - `organisation`: Processes organisation-related requests +//! - `question`: Handles question-related requests +//! - `rating`: Processes rating-related requests +//! - `role`: Handles role-related requests +//! - `user`: Processes user-related requests + +pub mod answer; +pub mod application; +pub mod auth; +pub mod campaign; +pub mod email_template; +pub mod offer; +pub mod organisation; +pub mod question; +pub mod rating; +pub mod role; +pub mod user; diff --git a/backend/server/src/handler/offer.rs b/backend/server/src/handler/offer.rs new file mode 100644 index 000000000..ffbd54bcc --- /dev/null +++ b/backend/server/src/handler/offer.rs @@ -0,0 +1,144 @@ +//! Offer handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing offers, including: +//! - Creating and retrieving offers +//! - Replying to offers +//! - Previewing and sending offer emails + +use crate::models::app::AppState; +use crate::models::auth::{OfferAdmin, OfferRecipient}; +use crate::models::error::ChaosError; +use crate::models::offer::{Offer, OfferReply}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for offer-related HTTP requests. +pub struct OfferHandler; + +impl OfferHandler { + /// Retrieves the details of a specific offer. + /// + /// This handler allows offer admins to view offer details. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the offer to retrieve + /// * `_user` - The authenticated user (must be an offer admin) + /// + /// # Returns + /// + /// * `Result` - Offer details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + let offer = Offer::get(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(offer))) + } + + /// Deletes an offer. + /// + /// This handler allows offer admins to delete offers. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the offer to delete + /// * `_user` - The authenticated user (must be an offer admin) + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + Offer::delete(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted offer")) + } + + /// Allows a recipient to reply to an offer. + /// + /// This handler allows offer recipients to accept or decline offers. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the offer to reply to + /// * `_user` - The authenticated user (must be the offer recipient) + /// * `reply` - The recipient's response + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn reply( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferRecipient, + Json(reply): Json, + ) -> Result { + Offer::reply(id, reply.accept, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully accepted offer")) + } + + /// Previews the email that will be sent for an offer. + /// + /// This handler allows offer admins to preview the offer email before sending. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the offer + /// * `_user` - The authenticated user (must be an offer admin) + /// + /// # Returns + /// + /// * `Result` - Email preview or error + pub async fn preview_email( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + let email_parts = Offer::preview_email(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(email_parts))) + } + + /// Sends an offer email to the recipient. + /// + /// This handler allows offer admins to send offer emails. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the offer to send + /// * `_user` - The authenticated user (must be an offer admin) + /// * `state` - The application state containing email credentials + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn send_offer( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + State(state): State, + ) -> Result { + Offer::send_offer(id, &mut transaction.tx, state.email_credentials).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully sent offer")) + } +} diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs new file mode 100644 index 000000000..fc36012a6 --- /dev/null +++ b/backend/server/src/handler/organisation.rs @@ -0,0 +1,495 @@ +//! Organisation handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing organisations, including: +//! - Organisation CRUD operations +//! - Member and admin management +//! - Campaign management +//! - Email template management +//! - Logo image handling + +use crate::models::app::AppState; +use crate::models::auth::SuperUser; +use crate::models::auth::{AuthUser, OrganisationAdmin}; +use crate::models::campaign::{Campaign, NewCampaign}; +use crate::models::email_template::{EmailTemplate, NewEmailTemplate}; +use crate::models::error::ChaosError; +use crate::models::organisation::{ + AdminToRemove, AdminUpdateList, NewOrganisation, Organisation, SlugCheck, +}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for organisation-related HTTP requests. +pub struct OrganisationHandler; + +impl OrganisationHandler { + /// Creates a new organisation. + /// + /// This handler allows super users to create new organisations. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `_user` - The authenticated user (must be a super user) + /// * `transaction` - Database transaction + /// * `data` - The new organisation details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create( + State(mut state): State, + _user: SuperUser, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Organisation::create( + data.admin, + data.slug, + data.name, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created organisation")) + } + + /// Checks if an organisation slug is available. + /// + /// This handler allows super users to check slug availability. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `_user` - The authenticated user (must be a super user) + /// * `data` - The slug to check + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn check_organisation_slug_availability( + mut transaction: DBTransaction<'_>, + _user: SuperUser, + Json(data): Json, + ) -> Result { + Organisation::check_slug_availability(data.slug, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Organisation slug is available")) + } + + /// Retrieves an organisation by its ID. + /// + /// This handler allows any authenticated user to view organisation details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation to retrieve + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - Organisation details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let org = Organisation::get(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(org))) + } + + /// Retrieves an organisation by its slug. + /// + /// This handler allows any authenticated user to view organisation details using a slug. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `slug` - The slug of the organisation + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - Organisation details or error + pub async fn get_by_slug( + mut transaction: DBTransaction<'_>, + Path(slug): Path, + _user: AuthUser, + ) -> Result { + let org = Organisation::get_by_slug(slug, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(org))) + } + + /// Deletes an organisation. + /// + /// This handler allows super users to delete organisations. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation to delete + /// * `_user` - The authenticated user (must be a super user) + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: SuperUser, + ) -> Result { + Organisation::delete(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully deleted organisation")) + } + + /// Get all organisations that the logged in user is an Admin of + pub async fn get_by_admin( + mut transaction: DBTransaction<'_>, + user: AuthUser, + ) -> Result { + let orgs = Organisation::get_by_admin(user.user_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(orgs))) + } + + /// Retrieves all admins of an organisation. + /// + /// This handler allows super users to view organisation admins. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation + /// * `_user` - The authenticated user (must be a super user) + /// + /// # Returns + /// + /// * `Result` - List of admins or error + pub async fn get_admins( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: SuperUser, + ) -> Result { + let members = Organisation::get_admins(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(members))) + } + + /// Retrieves all members of an organisation. + /// + /// This handler allows organisation admins to view all members. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation + /// * `_admin` - The authenticated user (must be an organisation admin) + /// + /// # Returns + /// + /// * `Result` - List of members or error + pub async fn get_members( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: OrganisationAdmin, + ) -> Result { + let members = Organisation::get_members(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(members))) + } + + /// Updates the admin list of an organisation. + /// + /// This handler allows super users to update organisation admins. + /// + /// # Arguments + /// + /// * `id` - The ID of the organisation + /// * `_super_user` - The authenticated user (must be a super user) + /// * `transaction` - Database transaction + /// * `request_body` - The new admin list + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_admins( + Path(id): Path, + _super_user: SuperUser, + mut transaction: DBTransaction<'_>, + Json(request_body): Json, + ) -> Result { + Organisation::update_admins(id, request_body.members, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated organisation members")) + } + + /// Updates the member list of an organisation. + /// + /// This handler allows organisation admins to update members. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the organisation + /// * `_admin` - The authenticated user (must be an organisation admin) + /// * `request_body` - The new member list + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_members( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::update_members(id, request_body.members, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated organisation members")) + } + + /// Removes an admin from an organisation. + /// + /// This handler allows super users to remove admins. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the organisation + /// * `_super_user` - The authenticated user (must be a super user) + /// * `request_body` - The admin to remove + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn remove_admin( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _super_user: SuperUser, + Json(request_body): Json, + ) -> Result { + Organisation::remove_admin(id, request_body.user_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok(( + StatusCode::OK, + "Successfully removed member from organisation", + )) + } + + /// Removes a member from an organisation. + /// + /// This handler allows organisation admins to remove members. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `id` - The ID of the organisation + /// * `_admin` - The authenticated user (must be an organisation admin) + /// * `request_body` - The member to remove + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn remove_member( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::remove_member(id, request_body.user_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok(( + StatusCode::OK, + "Successfully removed member from organisation", + )) + } + + /// Updates an organisation's logo. + /// + /// This handler allows organisation admins to update the logo. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation + /// * `_admin` - The authenticated user (must be an organisation admin) + /// + /// # Returns + /// + /// * `Result` - Logo URL or error + pub async fn update_logo( + State(state): State, + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: OrganisationAdmin, + ) -> Result { + let logo_url = Organisation::update_logo(id, &mut transaction.tx, &state.storage_bucket).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(logo_url))) + } + + /// Retrieves all campaigns for an organisation. + /// + /// This handler allows any authenticated user to view organisation campaigns. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the organisation + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - List of campaigns or error + pub async fn get_campaigns( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let campaigns = Organisation::get_campaigns(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(campaigns))) + } + + /// Creates a new campaign for an organisation. + /// + /// This handler allows organisation admins to create campaigns. + /// + /// # Arguments + /// + /// * `id` - The ID of the organisation + /// * `state` - The application state + /// * `_admin` - The authenticated user (must be an organisation admin) + /// * `request_body` - The new campaign details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_campaign( + Path(id): Path, + State(mut state): State, + mut transaction: DBTransaction<'_>, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::create_campaign( + id, + request_body.slug, + request_body.name, + request_body.description, + request_body.starts_at, + request_body.ends_at, + &mut transaction.tx, + &mut state.snowflake_generator, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created campaign")) + } + + /// Checks if a campaign slug is available. + /// + /// This handler allows organisation admins to check slug availability. + /// + /// # Arguments + /// + /// * `organisation_id` - The ID of the organisation + /// * `state` - The application state + /// * `_user` - The authenticated user (must be an organisation admin) + /// * `data` - The slug to check + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn check_campaign_slug_availability( + Path(organisation_id): Path, + mut transaction: DBTransaction<'_>, + _user: OrganisationAdmin, + Json(data): Json, + ) -> Result { + Campaign::check_slug_availability(organisation_id, data.slug, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Campaign slug is available")) + } + + /// Creates a new email template for an organisation. + /// + /// This handler allows organisation admins to create email templates. + /// + /// # Arguments + /// + /// * `id` - The ID of the organisation + /// * `state` - The application state + /// * `_admin` - The authenticated user (must be an organisation admin) + /// * `request_body` - The new template details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn create_email_template( + Path(id): Path, + State(mut state): State, + mut transaction: DBTransaction<'_>, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::create_email_template( + id, + request_body.name, + request_body.template_subject, + request_body.template_body, + &mut transaction.tx, + &mut state.snowflake_generator, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created email template")) + } + + /// Retrieves all email templates for an organisation. + /// + /// This handler allows organisation admins to view all email templates. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be an organisation admin) + /// * `id` - The ID of the organisation + /// * `state` - The application state + /// + /// # Returns + /// + /// * `Result` - List of email templates or error + pub async fn get_all_email_templates( + _user: OrganisationAdmin, + Path(id): Path, + mut transaction: DBTransaction<'_>, + ) -> Result { + let email_templates = EmailTemplate::get_all_by_organisation(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(email_templates))) + } +} diff --git a/backend/server/src/handler/question.rs b/backend/server/src/handler/question.rs new file mode 100644 index 000000000..82c6d0f5d --- /dev/null +++ b/backend/server/src/handler/question.rs @@ -0,0 +1,180 @@ +//! Question handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing questions, including: +//! - Creating and retrieving questions +//! - Updating and deleting questions +//! - Managing role-specific and common questions + +use crate::models::app::AppState; +use crate::models::auth::{AuthUser, CampaignAdmin, QuestionAdmin}; +use crate::models::error::ChaosError; +use crate::models::question::{NewQuestion, Question}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; + +/// Handler for question-related HTTP requests. +pub struct QuestionHandler; + +impl QuestionHandler { + /// Creates a new question for a campaign. + /// + /// This handler allows campaign admins to create questions. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `campaign_id` - The ID of the campaign + /// * `_admin` - The authenticated user (must be a campaign admin) + /// * `transaction` - Database transaction + /// * `data` - The new question details + /// + /// # Returns + /// + /// * `Result` - Question ID or error + pub async fn create( + State(mut state): State, + Path(campaign_id): Path, + _admin: CampaignAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let id = Question::create( + campaign_id, + data.title, + data.description, + data.common, + data.roles, + data.required, + data.question_data, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(json!({"id": id})))) + } + + /// Retrieves all questions for a specific role in a campaign. + /// + /// This handler allows any authenticated user to view role-specific questions. + /// + /// # Arguments + /// + /// * `campaign_id` - The ID of the campaign + /// * `role_id` - The ID of the role + /// * `_user` - The authenticated user + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of questions or error + pub async fn get_all_by_campaign_and_role( + Path((campaign_id, role_id)): Path<(i64, i64)>, + _user: AuthUser, + mut transaction: DBTransaction<'_>, + ) -> Result { + let questions = + Question::get_all_by_campaign_and_role(campaign_id, role_id, &mut transaction.tx) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(questions))) + } + + /// Retrieves all common questions for a campaign. + /// + /// This handler allows any authenticated user to view common questions. + /// + /// # Arguments + /// + /// * `campaign_id` - The ID of the campaign + /// * `_user` - The authenticated user + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of questions or error + pub async fn get_all_common_by_campaign( + Path(campaign_id): Path, + _user: AuthUser, + mut transaction: DBTransaction<'_>, + ) -> Result { + let questions = + Question::get_all_common_by_campaign(campaign_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(questions))) + } + + /// Updates a question. + /// + /// This handler allows question admins to update question details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `question_id` - The ID of the question to update + /// * `_admin` - The authenticated user (must be a question admin) + /// * `transaction` - Database transaction + /// * `data` - The new question details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + State(mut state): State, + Path(question_id): Path, + _admin: QuestionAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Question::update( + question_id, + data.title, + data.description, + data.common, + data.roles, + data.required, + data.question_data, + &mut transaction.tx, + &mut state.snowflake_generator, + ) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully updated question")) + } + + /// Deletes a question. + /// + /// This handler allows question admins to delete questions. + /// + /// # Arguments + /// + /// * `question_id` - The ID of the question to delete + /// * `_admin` - The authenticated user (must be a question admin) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + Path(question_id): Path, + _admin: QuestionAdmin, + mut transaction: DBTransaction<'_>, + ) -> Result { + Question::delete(question_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted question")) + } +} diff --git a/backend/server/src/handler/rating.rs b/backend/server/src/handler/rating.rs new file mode 100644 index 000000000..1ef1ade58 --- /dev/null +++ b/backend/server/src/handler/rating.rs @@ -0,0 +1,118 @@ +//! Rating handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing application ratings, including: +//! - Updating ratings +//! - Retrieving rating details +//! - Deleting ratings + +use crate::models::app::AppState; +use crate::models::auth::{ + ApplicationReviewerGivenApplicationId, ApplicationReviewerGivenRatingId, RatingCreator, +}; +use crate::models::error::ChaosError; +use crate::models::rating::{NewRating, Rating}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for rating-related HTTP requests. +pub struct RatingHandler; + +impl RatingHandler { + pub async fn create( + State(mut state): State, + Path(application_id): Path, + admin: ApplicationReviewerGivenApplicationId, + mut transaction: DBTransaction<'_>, + Json(new_rating): Json, + ) -> Result { + Rating::create( + new_rating, + application_id, + admin.user_id, + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created rating")) + } + + /// Updates an existing rating. + /// + /// This handler allows the creator of a rating to update its details. + /// + /// # Arguments + /// + /// * `_state` - The application state + /// * `rating_id` - The ID of the rating to update + /// * `_admin` - The authenticated user (must be the rating creator) + /// * `transaction` - Database transaction + /// * `updated_rating` - The new rating details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + State(_state): State, + Path(rating_id): Path, + _admin: RatingCreator, + mut transaction: DBTransaction<'_>, + Json(updated_rating): Json, + ) -> Result { + Rating::update(rating_id, updated_rating, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated rating")) + } + + /// Retrieves the details of a specific rating. + /// + /// This handler allows application reviewers to view rating details. + /// + /// # Arguments + /// + /// * `_state` - The application state + /// * `rating_id` - The ID of the rating to retrieve + /// * `_admin` - The authenticated user (must be an application reviewer) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Rating details or error + pub async fn get( + State(_state): State, + Path(rating_id): Path, + _admin: ApplicationReviewerGivenRatingId, + mut transaction: DBTransaction<'_>, + ) -> Result { + let org = Rating::get_rating(rating_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(org))) + } + + /// Deletes a rating. + /// + /// This handler allows the creator of a rating to delete it. + /// + /// # Arguments + /// + /// * `_state` - The application state + /// * `rating_id` - The ID of the rating to delete + /// * `_admin` - The authenticated user (must be the rating creator) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + State(_state): State, + Path(rating_id): Path, + _admin: RatingCreator, + mut transaction: DBTransaction<'_>, + ) -> Result { + Rating::delete(rating_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully deleted rating")) + } +} diff --git a/backend/server/src/handler/role.rs b/backend/server/src/handler/role.rs new file mode 100644 index 000000000..31f270a77 --- /dev/null +++ b/backend/server/src/handler/role.rs @@ -0,0 +1,118 @@ +//! Role handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing campaign roles, including: +//! - Retrieving role details +//! - Updating and deleting roles +//! - Managing role applications + +use crate::models::app::AppState; +use crate::models::application::Application; +use crate::models::auth::{AuthUser, RoleAdmin}; +use crate::models::error::ChaosError; +use crate::models::role::{Role, RoleUpdate}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +/// Handler for role-related HTTP requests. +pub struct RoleHandler; + +impl RoleHandler { + /// Retrieves the details of a specific role. + /// + /// This handler allows any authenticated user to view role details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the role to retrieve + /// * `_user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - Role details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let role = Role::get(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(role))) + } + + /// Deletes a role. + /// + /// This handler allows role admins to delete roles. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the role to delete + /// * `_admin` - The authenticated user (must be a role admin) + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn delete( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: RoleAdmin, + ) -> Result { + Role::delete(id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully deleted role")) + } + + /// Updates a role. + /// + /// This handler allows role admins to update role details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `id` - The ID of the role to update + /// * `_admin` - The authenticated user (must be a role admin) + /// * `data` - The new role details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: RoleAdmin, + Json(data): Json, + ) -> Result { + Role::update(id, data, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated role")) + } + + /// Retrieves all applications for a specific role. + /// + /// This handler allows role admins to view all applications for a role. + /// + /// # Arguments + /// + /// * `id` - The ID of the role + /// * `_admin` - The authenticated user (must be a role admin) + /// * `transaction` - Database transaction + /// + /// # Returns + /// + /// * `Result` - List of applications or error + pub async fn get_applications( + Path(id): Path, + _admin: RoleAdmin, + mut transaction: DBTransaction<'_>, + ) -> Result { + let applications = Application::get_from_role_id(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(applications))) + } +} diff --git a/backend/server/src/handler/user.rs b/backend/server/src/handler/user.rs new file mode 100644 index 000000000..bb9043dcb --- /dev/null +++ b/backend/server/src/handler/user.rs @@ -0,0 +1,167 @@ +//! User handler for the Chaos application. +//! +//! This module provides HTTP request handlers for managing user profiles, including: +//! - Retrieving user details +//! - Updating user information (name, pronouns, gender, zid, degree) + +use crate::models::app::AppState; +use crate::models::auth::AuthUser; +use crate::models::error::ChaosError; +use crate::models::user::{User, UserDegree, UserGender, UserName, UserPronouns, UserZid}; +use axum::extract::{Json, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use crate::models::transaction::DBTransaction; + +/// Handler for user-related HTTP requests. +pub struct UserHandler; + +impl UserHandler { + /// Retrieves the details of the current user. + /// + /// This handler allows authenticated users to view their profile details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// + /// # Returns + /// + /// * `Result` - User details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + user: AuthUser, + ) -> Result { + let user = User::get(user.user_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(user))) + } + + /// Updates the user's name. + /// + /// This handler allows users to update their name. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// * `request_body` - The new name + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_name( + mut transaction: DBTransaction<'_>, + user: AuthUser, + Json(request_body): Json, + ) -> Result { + User::update_name(user.user_id, request_body.name, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Updated username")) + } + + /// Updates the user's pronouns. + /// + /// This handler allows users to update their pronouns. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// * `request_body` - The new pronouns + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_pronouns( + mut transaction: DBTransaction<'_>, + user: AuthUser, + Json(request_body): Json, + ) -> Result { + User::update_pronouns(user.user_id, request_body.pronouns, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Updated pronouns")) + } + + /// Updates the user's gender. + /// + /// This handler allows users to update their gender. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// * `request_body` - The new gender + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_gender( + mut transaction: DBTransaction<'_>, + user: AuthUser, + Json(request_body): Json, + ) -> Result { + User::update_gender(user.user_id, request_body.gender, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Updated gender")) + } + + /// Updates the user's zid. + /// + /// This handler allows users to update their zid. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// * `request_body` - The new zid + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_zid( + mut transaction: DBTransaction<'_>, + user: AuthUser, + Json(request_body): Json, + ) -> Result { + User::update_zid(user.user_id, request_body.zid, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Updated zid")) + } + + /// Updates the user's degree information. + /// + /// This handler allows users to update their degree details. + /// + /// # Arguments + /// + /// * `state` - The application state + /// * `user` - The authenticated user + /// * `request_body` - The new degree details + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn update_degree( + mut transaction: DBTransaction<'_>, + user: AuthUser, + Json(request_body): Json, + ) -> Result { + User::update_degree( + user.user_id, + request_body.degree_name, + request_body.degree_starting_year, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Updated user degree")) + } +} diff --git a/backend/server/src/images.rs b/backend/server/src/images.rs deleted file mode 100644 index c7255beb4..000000000 --- a/backend/server/src/images.rs +++ /dev/null @@ -1,86 +0,0 @@ -use image::{io::Reader as ImageReader, DynamicImage, ImageError}; -use rocket::{data::ToByteUnit, Data}; -use std::{ - fs, - io::{self, Cursor}, - path::{Path, PathBuf}, -}; -use strum::{EnumIter, IntoEnumIterator}; -use webp::Encoder; - -pub enum ImageDecodeError { - ImageError(ImageError), - IoError(io::Error), -} - -impl From for ImageDecodeError { - fn from(error: ImageError) -> Self { - ImageDecodeError::ImageError(error) - } -} - -impl From for ImageDecodeError { - fn from(error: io::Error) -> Self { - ImageDecodeError::IoError(error) - } -} - -pub fn try_decode_bytes(bytes: Vec) -> Result { - ImageReader::new(Cursor::new(bytes)) - .with_guessed_format()? - .decode() -} - -#[rustfmt::skip] -pub async fn try_decode_data(data: Data<'_>) -> Result { - data.open(5.mebibytes()).into_bytes().await.map(|bytes| try_decode_bytes(bytes.to_vec())).map_err(|e| e.into()).and_then(|v| v.map_err(|e| e.into())) -} - -const HTTP_IMAGE_BASE_PATH: &str = "/api/static/images/"; -pub const IMAGE_BASE_PATH: &str = "./images/"; - -#[derive(EnumIter)] -pub enum ImageLocation { - CAMPAIGNS, - ORGANISATIONS, -} - -pub fn image_location_to_string(location: ImageLocation) -> String { - match location { - ImageLocation::CAMPAIGNS => "campaigns/", - ImageLocation::ORGANISATIONS => "organisations/", - } - .to_string() -} - -pub fn save_image( - image: DynamicImage, - location: ImageLocation, - image_uuid: &str, -) -> std::io::Result { - ImageLocation::iter().for_each(|location| { - fs::create_dir_all(Path::new(IMAGE_BASE_PATH).join(image_location_to_string(location))) - .ok(); - }); - - let path = get_image_path(location, image_uuid); - - let encoder = Encoder::from_image(&image).unwrap(); - let webp = encoder.encode(80.0); - std::fs::write(&path, &*webp).map(|_| path) -} - -pub fn get_image_path(location: ImageLocation, image_uuid: &str) -> PathBuf { - Path::new(IMAGE_BASE_PATH) - .join(image_location_to_string(location)) - .join(image_uuid) -} - -pub fn get_http_image_path(location: ImageLocation, image_uuid: &str) -> String { - Path::new(HTTP_IMAGE_BASE_PATH) - .join(image_location_to_string(location)) - .join(image_uuid) - .to_str() - .unwrap() - .to_string() -} diff --git a/backend/server/src/lib.rs b/backend/server/src/lib.rs index 80b721c5e..c849d58e8 100644 --- a/backend/server/src/lib.rs +++ b/backend/server/src/lib.rs @@ -1,20 +1,3 @@ -#[macro_use] -extern crate diesel; - -pub mod admin; -pub mod application; -pub mod auth; -pub mod campaigns; -pub mod comment; -pub mod cors; -pub mod database; -pub mod error; -pub mod guard; -pub mod images; -pub mod organisation; -pub mod permissions; -pub mod question; -pub mod role; -pub mod state; -pub mod static_resources; -pub mod user; +pub mod handler; +pub mod models; +pub mod service; \ No newline at end of file diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs new file mode 100644 index 000000000..105fc5a0e --- /dev/null +++ b/backend/server/src/main.rs @@ -0,0 +1,17 @@ +use crate::models::app::app; +use crate::models::error::ChaosError; + +mod handler; +mod models; +mod service; + +#[tokio::main] +async fn main() -> Result<(), ChaosError> { + dotenvy::dotenv()?; + + let app = app().await?; + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); + axum::serve(listener, app).await.unwrap(); + + Ok(()) +} \ No newline at end of file diff --git a/backend/server/src/models/answer.rs b/backend/server/src/models/answer.rs new file mode 100644 index 000000000..0ab1f97a5 --- /dev/null +++ b/backend/server/src/models/answer.rs @@ -0,0 +1,649 @@ +//! Answer management module for the Chaos application. +//! +//! This module provides functionality for managing answers to application questions, +//! including creation, retrieval, updating, and deletion of answers. It supports +//! various question types such as short answer, multiple choice, and ranking questions. + +use crate::models::error::ChaosError; +use crate::models::question::QuestionType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents an answer in the system. +/// +/// An answer is a response to a question in an application. The answer data is +/// stored in a type-specific format based on the question type. +/// +/// With the chosen `serde` representation and the use of `#[serde(flatten)]`, the JSON for a +/// `Answer` will look like this: +/// ```json +/// { +/// "id": 7233828375289773948, +/// "question_id": 7233828375289139200, +/// "answer_type": "MultiChoice", +/// "data": 7233828393325384908, +/// "created_at": "2024-06-28T16:29:04.644008111Z", +/// "updated_at": "2024-06-30T12:14:12.458390190Z" +/// } +/// ``` +#[derive(Deserialize, Serialize)] +pub struct Answer { + /// Unique identifier for the answer + id: i64, + /// ID of the question this answer is for + question_id: i64, + + /// The actual answer data, flattened in serialization + #[serde(flatten)] + answer_data: AnswerData, + + /// When the answer was created + created_at: DateTime, + /// When the answer was last updated + updated_at: DateTime, +} + +/// Data structure for creating a new answer. +/// +/// Contains the question ID and the answer data. +#[derive(Deserialize)] +pub struct NewAnswer { + /// ID of the question this answer is for + pub question_id: i64, + + /// The actual answer data, flattened in serialization + #[serde(flatten)] + pub answer_data: AnswerData, +} + +/// Raw answer data from the database. +/// +/// Contains all fields needed to construct an Answer structure, +/// including the question type and various answer formats. +#[derive(Deserialize, sqlx::FromRow)] +pub struct AnswerRawData { + /// Unique identifier for the answer + id: i64, + /// ID of the question this answer is for + question_id: i64, + /// Type of the question + question_type: QuestionType, + /// Text answer for short answer questions + short_answer_answer: Option, + /// Selected options for multiple choice/select questions + multi_option_answers: Option>, + /// Ranked options for ranking questions + ranking_answers: Option>, + /// When the answer was created + created_at: DateTime, + /// When the answer was last updated + updated_at: DateTime, +} + +/// Data structure for identifying an answer by type and application. +#[derive(Deserialize)] +pub struct AnswerTypeApplicationId { + /// Type of the question + question_type: QuestionType, + /// ID of the application this answer belongs to + application_id: i64, +} + +impl Answer { + /// Creates a new answer. + /// + /// # Arguments + /// + /// * `application_id` - ID of the application this answer belongs to + /// * `question_id` - ID of the question being answered + /// * `answer_data` - The answer data + /// * `snowflake_generator` - Generator for creating unique IDs + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - ID of the created answer or error + pub async fn create( + application_id: i64, + question_id: i64, + answer_data: AnswerData, + snowflake_generator: &mut SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + answer_data.validate()?; + + let id = snowflake_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO answers (id, application_id, question_id) + VALUES ($1, $2, $3) + ", + id, + application_id, + question_id + ) + .execute(transaction.deref_mut()) + .await?; + + answer_data.insert_into_db(id, transaction).await?; + + Ok(id) + } + + /// Retrieves an answer by its ID. + /// + /// # Arguments + /// + /// * `id` - ID of the answer to retrieve + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - Answer details or error + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let answer_raw_data = sqlx::query_as!( + AnswerRawData, + r#" + SELECT + a.id, + a.question_id, + q.question_type AS "question_type: QuestionType", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE q.id = $1 + GROUP BY + a.id, q.question_type, saa.text + "#, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Ok(Answer { + id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + }) + } + + /// Retrieves all common answers for an application. + /// + /// Common answers are those that apply to all roles in the application. + /// + /// # Arguments + /// + /// * `application_id` - ID of the application to get answers for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of answers or error + pub async fn get_all_common_by_application( + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let answer_raw_data = sqlx::query_as!( + AnswerRawData, + r#" + SELECT + a.id, + a.question_id, + q.question_type AS "question_type: QuestionType", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND q.common = true + GROUP BY + a.id, q.question_type, saa.text + "#, + application_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data + .into_iter() + .map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }) + .collect(); + + Ok(answers) + } + + /// Retrieves all answers for an application and role. + /// + /// # Arguments + /// + /// * `application_id` - ID of the application to get answers for + /// * `role_id` - ID of the role to get answers for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of answers or error + pub async fn get_all_by_application_and_role( + application_id: i64, + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let answer_raw_data = sqlx::query_as!( + AnswerRawData, + r#" + SELECT + a.id, + a.question_id, + q.question_type AS "question_type: QuestionType", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + JOIN question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND qr.role_id = $2 AND q.common = true + GROUP BY + a.id, q.question_type, saa.text + "#, + application_id, + role_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data + .into_iter() + .map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }) + .collect(); + + Ok(answers) + } + + /// Updates an existing answer. + /// + /// # Arguments + /// + /// * `id` - ID of the answer to update + /// * `answer_data` - New answer data + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update( + id: i64, + answer_data: AnswerData, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + answer_data.validate()?; + + let answer = sqlx::query_as!( + AnswerTypeApplicationId, + r#" + SELECT a.application_id, q.question_type AS "question_type: QuestionType" + FROM answers a + JOIN questions q ON a.question_id = q.id + WHERE a.id = $1 + "#, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = AnswerData::from_question_type(&answer.question_type); + old_data.delete_from_db(id, transaction).await?; + + answer_data.insert_into_db(id, transaction).await?; + + sqlx::query!( + "UPDATE applications SET updated_at = $1 WHERE id = $2", + Utc::now(), + answer.application_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Deletes an answer. + /// + /// # Arguments + /// + /// * `id` - ID of the answer to delete + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!("DELETE FROM answers WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + +/// Represents the different types of answer data. +/// +/// Each variant corresponds to a different question type and contains +/// the appropriate data format for that type. +#[derive(Deserialize, Serialize)] +pub enum AnswerData { + /// Text answer for short answer questions + ShortAnswer(String), + /// Single selected option for multiple choice questions + MultiChoice(i64), + /// Multiple selected options for multi-select questions + MultiSelect(Vec), + /// Single selected option for dropdown questions + DropDown(i64), + /// Ranked list of options for ranking questions + Ranking(Vec), +} + +impl AnswerData { + /// Creates a new AnswerData instance based on a question type. + /// + /// # Arguments + /// + /// * `question_type` - Type of the question + /// + /// # Returns + /// + /// * `AnswerData` - New answer data instance + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => AnswerData::ShortAnswer("".to_string()), + QuestionType::MultiChoice => AnswerData::MultiChoice(0), + QuestionType::MultiSelect => AnswerData::MultiSelect(Vec::::new()), + QuestionType::DropDown => AnswerData::DropDown(0), + QuestionType::Ranking => AnswerData::Ranking(Vec::::new()), + } + } + + /// Creates an AnswerData instance from raw database data. + /// + /// # Arguments + /// + /// * `question_type` - Type of the question + /// * `short_answer_answer` - Text answer for short answer questions + /// * `multi_option_answers` - Selected options for multiple choice/select questions + /// * `ranking_answers` - Ranked options for ranking questions + /// + /// # Returns + /// + /// * `AnswerData` - New answer data instance + fn from_answer_raw_data( + question_type: QuestionType, + short_answer_answer: Option, + multi_option_answers: Option>, + ranking_answers: Option>, + ) -> Self { + match question_type { + QuestionType::ShortAnswer => { + let answer = + short_answer_answer.expect("Data should exist for ShortAnswer variant"); + AnswerData::ShortAnswer(answer) + } + QuestionType::MultiChoice | QuestionType::MultiSelect | QuestionType::DropDown => { + let options = + multi_option_answers.expect("Data should exist for MultiOptionData variants"); + + match question_type { + QuestionType::MultiChoice => AnswerData::MultiChoice(options[0]), + QuestionType::MultiSelect => AnswerData::MultiSelect(options), + QuestionType::DropDown => AnswerData::DropDown(options[0]), + _ => AnswerData::ShortAnswer("".to_string()), // Should never be reached, hence return ShortAnswer + } + } + QuestionType::Ranking => { + let options = ranking_answers.expect("Data should exist for Ranking variant"); + AnswerData::Ranking(options) + } + } + } + + /// Validates the answer data. + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success if valid, error if not + pub fn validate(&self) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer(text) => { + if text.is_empty() { + return Err(ChaosError::BadRequest); + } + } + Self::MultiSelect(data) | Self::Ranking(data) => { + if data.is_empty() { + return Err(ChaosError::BadRequest); + } + } + _ => {} + } + + Ok(()) + } + + /// Inserts the answer data into the database. + /// + /// # Arguments + /// + /// * `answer_id` - ID of the answer to insert data for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn insert_into_db( + self, + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer(text) => { + sqlx::query!( + "INSERT INTO short_answer_answers (text, answer_id) VALUES ($1, $2)", + text, + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + Self::MultiChoice(option_id) | Self::DropDown(option_id) => { + sqlx::query!( + "INSERT INTO multi_option_answer_options (option_id, answer_id) VALUES ($1, $2)", + option_id, + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + Self::MultiSelect(option_ids) => { + let mut query_builder = sqlx::QueryBuilder::new( + "INSERT INTO multi_option_answer_options (option_id, answer_id)", + ); + + query_builder.push_values(option_ids, |mut b, option_id| { + b.push_bind(option_id).push_bind(answer_id); + }); + + let query = query_builder.build(); + query.execute(transaction.deref_mut()).await?; + + Ok(()) + } + Self::Ranking(option_ids) => { + let mut query_builder = sqlx::QueryBuilder::new( + "INSERT INTO ranking_answer_rankings (option_id, rank, answer_id)", + ); + + let mut rank = 1; + query_builder.push_values(option_ids, |mut b, option_id| { + b.push_bind(option_id).push_bind(rank).push_bind(answer_id); + rank += 1; + }); + + let query = query_builder.build(); + query.execute(transaction.deref_mut()).await?; + + Ok(()) + } + } + } + + /// Deletes the answer data from the database. + /// + /// # Arguments + /// + /// * `answer_id` - ID of the answer to delete data for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn delete_from_db( + self, + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer(_) => { + sqlx::query!( + "DELETE FROM short_answer_answers WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + Self::MultiChoice(_) | Self::MultiSelect(_) | Self::DropDown(_) => { + sqlx::query!( + "DELETE FROM multi_option_answer_options WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + Self::Ranking(_) => { + sqlx::query!( + "DELETE FROM ranking_answer_rankings WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs new file mode 100644 index 000000000..12a652b8b --- /dev/null +++ b/backend/server/src/models/app.rs @@ -0,0 +1,343 @@ +use crate::handler::answer::AnswerHandler; +use crate::handler::application::ApplicationHandler; +use crate::handler::auth::{google_callback, google_auth_init, DevLoginHandler}; +use crate::handler::campaign::CampaignHandler; +use crate::handler::email_template::EmailTemplateHandler; +use crate::handler::offer::OfferHandler; +use crate::handler::organisation::OrganisationHandler; +use crate::handler::question::QuestionHandler; +use crate::handler::rating::RatingHandler; +use crate::handler::role::RoleHandler; +use crate::handler::user::UserHandler; +use crate::models::email::{ChaosEmail, EmailCredentials}; +use crate::models::error::ChaosError; +use crate::models::storage::Storage; +use axum::routing::{delete, get, patch, post}; +use axum::Router; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use reqwest::Client as ReqwestClient; +use s3::Bucket; +use snowflake::SnowflakeIdGenerator; +use sqlx::postgres::PgPoolOptions; +use sqlx::{Pool, Postgres}; +use std::env; +use axum::http::{header, Method}; +use oauth2::basic::BasicClient; +use tower_http::cors::CorsLayer; +use crate::service::oauth2::build_oauth_client; + +#[derive(Clone)] +pub struct AppState { + pub db: Pool, + pub ctx: ReqwestClient, + pub oauth2_client: BasicClient, + pub decoding_key: DecodingKey, + pub encoding_key: EncodingKey, + pub jwt_header: Header, + pub jwt_validator: Validation, + pub snowflake_generator: SnowflakeIdGenerator, + pub storage_bucket: Bucket, + pub is_dev_env: bool, + pub email_credentials: EmailCredentials, +} + +pub async fn init_app_state() -> AppState { + // Initialise DB connection + let db_url = env::var("DATABASE_URL") + .expect("Error getting DATABASE_URL") + .to_string(); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(db_url.as_str()) + .await + .expect("Cannot connect to database"); + + // Initialise JWT settings + let jwt_secret = env::var("JWT_SECRET") + .expect("Error getting JWT_SECRET") + .to_string(); + // let jwt_secret = "I want to cry"; + let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes()); + let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); + let jwt_header = Header::new(Algorithm::HS512); + let mut jwt_validator = Validation::new(Algorithm::HS512); + jwt_validator.set_issuer(&["Chaos"]); + jwt_validator.set_audience(&["chaos.devsoc.app"]); + + // Initialise reqwest client + let ctx = reqwest::Client::new(); + + // Initialise oauth2 client + let client_id = env::var("GOOGLE_CLIENT_ID") + .expect("Error getting GOOGLE_CLIENT_ID") + .to_string(); + let client_secret = env::var("GOOGLE_CLIENT_SECRET") + .expect("Error getting GOOGLE_CLIENT_SECRET") + .to_string(); + let oauth2_client = build_oauth_client(client_id, client_secret); + + let dev_env = env::var("DEV_ENV") + .expect("Error getting DEV_ENV") + .to_string(); + + let mut is_dev_env = false; + + if dev_env == "dev" { + is_dev_env = true; + } + + // Initialise Snowflake Generator + let snowflake_generator = SnowflakeIdGenerator::new(1, 1); + + // Initialise S3 bucket + let storage_bucket = Storage::init_bucket(); + + // Initialise email credentials + let email_credentials = ChaosEmail::setup_credentials(); + + // Add all data to AppState + let state = AppState { + db: pool, + ctx, + oauth2_client, + encoding_key, + decoding_key, + jwt_header, + jwt_validator, + snowflake_generator, + storage_bucket, + is_dev_env, + email_credentials, + }; + + state +} + +pub async fn app() -> Result { + + let state = init_app_state().await; + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::PUT]) + .allow_headers([header::ACCEPT, header::COOKIE, header::SET_COOKIE, header::CONTENT_TYPE]) + .allow_credentials(true) + .allow_origin([ + "http://localhost".parse().unwrap(), + "http://localhost:3000".parse().unwrap(), + "https://chaos.devsoc.app".parse().unwrap(), + "http://chaos.devsoc.app".parse().unwrap(), + "https://chaosstaging.devsoc.app".parse().unwrap(), + "http://chaosstaging.devsoc.app".parse().unwrap(), + ]); + + Ok(Router::new() + .route("/", get(|| async { "Join DevSoc! https://devsoc.app/" })) + .route("/auth/google", get(google_auth_init)) + .route("/api/auth/callback/google", get(google_callback)) + .route("/api/v1/dev/super_admin_login", get(DevLoginHandler::dev_super_admin_login)) + .route("/api/v1/dev/org_admin_login", get(DevLoginHandler::dev_org_admin_login)) + .route("/api/v1/dev/user_login", get(DevLoginHandler::dev_user_login)) + .route("/api/v1/user", get(UserHandler::get)) + .route("/api/v1/user/name", patch(UserHandler::update_name)) + .route("/api/v1/user/pronouns", patch(UserHandler::update_pronouns)) + .route("/api/v1/user/gender", patch(UserHandler::update_gender)) + .route("/api/v1/user/zid", patch(UserHandler::update_zid)) + .route("/api/v1/user/degree", patch(UserHandler::update_degree)) + .route( + "/api/v1/user/applications", + get(ApplicationHandler::get_from_curr_user), + ) + .route("/api/v1/user/organisations", get(OrganisationHandler::get_by_admin)) + .route("/api/v1/organisation", post(OrganisationHandler::create)) + .route( + "/api/v1/organisation/slug_check", + post(OrganisationHandler::check_organisation_slug_availability), + ) + .route( + "/api/v1/organisation/:organisation_id", + get(OrganisationHandler::get).delete(OrganisationHandler::delete), + ) + .route( + "/api/v1/organisation/slug/:slug", + get(OrganisationHandler::get_by_slug), + ) + .route( + "/api/v1/organisation/:organisation_id/campaign", + post(OrganisationHandler::create_campaign), + ) + .route( + "/api/v1/organisation/:organisation_id/campaign/slug_check", + post(OrganisationHandler::check_campaign_slug_availability), + ) + .route( + "/api/v1/organisation/:organisation_id/campaigns", + get(OrganisationHandler::get_campaigns), + ) + .route( + "/api/v1/organisation/:organisation_id/email_template", + post(OrganisationHandler::create_email_template), + ) + .route( + "/api/v1/organisation/:organisation_id/email_templates", + get(OrganisationHandler::get_all_email_templates), + ) + .route( + "/api/v1/organisation/:organisation_id/logo", + patch(OrganisationHandler::update_logo), + ) + .route( + "/api/v1/organisation/:organisation_id/members", + get(OrganisationHandler::get_members) + .put(OrganisationHandler::update_members) + ) + .route( + "/api/v1/organisation/:organisation_id/member", + delete(OrganisationHandler::remove_member), + ) + .route( + "/api/v1/organisation/:organisation_id/admins", + get(OrganisationHandler::get_admins) + .put(OrganisationHandler::update_admins) + ) + .route( + "/api/v1/organisation/:organisation_id/admin", + delete(OrganisationHandler::remove_admin), + ) + .route( + "/api/v1/rating/:rating_id", + get(RatingHandler::get) + .delete(RatingHandler::delete) + .put(RatingHandler::update), + ) + .route( + "/api/v1/application/:application_id/rating", + post(ApplicationHandler::create_rating), + ) + .route( + "/api/v1/application/:application_id/ratings", + get(ApplicationHandler::get_ratings), + ) + .route( + "/api/v1/campaign/:campaign_id/role", + post(CampaignHandler::create_role), + ) + .route( + "/api/v1/campaign/:campaign_id/role/:role_id/questions", + get(QuestionHandler::get_all_by_campaign_and_role), + ) + .route( + "/api/v1/campaign/:campaign_id/roles", + get(CampaignHandler::get_roles), + ) + .route( + "/api/v1/campaign/:campaign_id/applications", + get(CampaignHandler::get_applications), + ) + .route( + "/api/v1/role/:role_id", + get(RoleHandler::get) + .put(RoleHandler::update) + .delete(RoleHandler::delete), + ) + .route( + "/api/v1/role/:role_id/applications", + get(RoleHandler::get_applications), + ) + .route( + "/api/v1/campaign/:campaign_id", + get(CampaignHandler::get) + .put(CampaignHandler::update) + .delete(CampaignHandler::delete), + ) + .route( + "/api/v1/campaign/slug/:organisation_slug/:campaign_slug", + get(CampaignHandler::get_by_slugs), + ) + .route("/api/v1/campaigns", get(CampaignHandler::get_all)) + .route( + "/api/v1/campaign/:campaign_id/question", + post(QuestionHandler::create), + ) + .route( + "/api/v1/campaign/:campaign_id/question/:id", + patch(QuestionHandler::update).delete(QuestionHandler::delete), + ) + .route( + "/api/v1/campaign/:campaign_id/questions/common", + get(QuestionHandler::get_all_common_by_campaign), + ) + .route( + "/api/v1/campaign/:campaign_id/banner", + patch(CampaignHandler::update_banner), + ) + .route( + "/api/v1/campaign/:campaign_id/application", + post(CampaignHandler::create_application), + ) + .route( + "/api/v1/campaign/:campaign_id/offer", + post(CampaignHandler::create_offer), + ) + .route( + "/api/v1/campaign/:campaign_id/offers", + get(CampaignHandler::get_offers), + ) + .route( + "/api/v1/application/:application_id", + get(ApplicationHandler::get), + ) + .route( + "/api/v1/application/:application_id/status", + patch(ApplicationHandler::set_status), + ) + .route( + "/api/v1/application/:application_id/private", + patch(ApplicationHandler::set_private_status), + ) + .route( + "/api/v1/application/:application_id/answers/common", + get(AnswerHandler::get_all_common_by_application), + ) + .route( + "/api/v1/application/:application_id/answer", + post(AnswerHandler::create), + ) + .route( + "/api/v1/application/:application_id/answers/role/:role_id", + get(AnswerHandler::get_all_by_application_and_role), + ) + .route( + "/api/v1/application/:application_id/roles", + patch(ApplicationHandler::update_roles) + ) + .route( + "/api/v1/application/:application_id/submit", + post(ApplicationHandler::submit) + ) + .route( + "/api/v1/answer/:answer_id", + patch(AnswerHandler::update).delete(AnswerHandler::delete), + ) + .route( + "/api/v1/email_template/:template_id", + get(EmailTemplateHandler::get) + .patch(EmailTemplateHandler::update) + .delete(EmailTemplateHandler::delete), + ) + .route( + "/api/v1/offer/:offer_id", + get(OfferHandler::get) + .delete(OfferHandler::delete) + .post(OfferHandler::reply), + ) + .route( + "/api/v1/offer/:offer_id/preview", + get(OfferHandler::preview_email), + ) + .route( + "/api/v1/offer/:offer_id/send", + post(OfferHandler::send_offer), + ) + .layer(cors) + .with_state(state)) +} diff --git a/backend/server/src/models/application.rs b/backend/server/src/models/application.rs new file mode 100644 index 000000000..f20230996 --- /dev/null +++ b/backend/server/src/models/application.rs @@ -0,0 +1,690 @@ +//! Application management module for the Chaos application. +//! +//! This module provides functionality for managing applications within recruitment campaigns, +//! including creating, retrieving, updating, and submitting applications. It also handles +//! application status management and role preferences. + +use std::collections::HashMap; +use crate::models::error::ChaosError; +use crate::models::user::UserDetails; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; +use axum::{async_trait, RequestPartsExt}; +use axum::extract::{FromRef, FromRequestParts, Path}; +use axum::http::request::Parts; +use crate::models::app::AppState; +use crate::service::answer::assert_answer_application_is_open; +use crate::service::application::{assert_application_is_open}; + +/// Represents an application in the system. +/// +/// An application is a user's submission for one or more roles within a campaign. +/// It tracks the application's status, both public and private, and maintains +/// timestamps for creation and updates. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Application { + /// Unique identifier for the application + pub id: i64, + /// ID of the campaign this application belongs to + pub campaign_id: i64, + /// ID of the user who submitted the application + pub user_id: i64, + /// Public status of the application + pub status: ApplicationStatus, + /// Private status of the application (visible only to admins) + pub private_status: ApplicationStatus, + /// Timestamp when the application was created + pub created_at: DateTime, + /// Timestamp when the application was last updated + pub updated_at: DateTime, +} + +/// Represents a role preference within an application. +/// +/// Users can apply for multiple roles in a single application, specifying their +/// preferences for each role. This struct links an application to a specific role +/// and includes the user's preference ranking. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct ApplicationRole { + /// Unique identifier for the role application + pub id: i64, + /// ID of the parent application + pub application_id: i64, + /// ID of the campaign role being applied for + pub campaign_role_id: i64, + /// User's preference ranking for this role (lower number = higher preference) + pub preference: i32, +} + +/// Data structure for creating a new application. +/// +/// Contains the list of roles the user is applying for, with their preferences. +#[derive(Deserialize, Serialize)] +pub struct NewApplication { + /// List of roles the user is applying for + pub applied_roles: Vec, +} + +/// Detailed view of an application, including user information and role preferences. +/// +/// This structure combines application data with user details and the specific roles +/// the user has applied for. +#[derive(Deserialize, Serialize)] +pub struct ApplicationDetails { + /// Unique identifier for the application + pub id: i64, + /// ID of the campaign this application belongs to + pub campaign_id: i64, + /// Details of the user who submitted the application + pub user: UserDetails, + /// Public status of the application + pub status: ApplicationStatus, + /// Private status of the application (visible only to admins) + pub private_status: ApplicationStatus, + /// List of roles the user has applied for, with details + pub applied_roles: Vec, +} + +/// Raw application data from the database. +/// +/// Contains all fields needed to construct an ApplicationDetails structure, +/// including user information and application status. +#[derive(Deserialize, Serialize)] +pub struct ApplicationData { + /// Unique identifier for the application + pub id: i64, + /// ID of the campaign this application belongs to + pub campaign_id: i64, + /// ID of the user who submitted the application + pub user_id: i64, + /// Email address of the applicant + pub user_email: String, + /// Student ID of the applicant + pub user_zid: Option, + /// Full name of the applicant + pub user_name: String, + /// Pronouns of the applicant + pub user_pronouns: Option, + /// Gender of the applicant + pub user_gender: Option, + /// Degree program of the applicant + pub user_degree_name: Option, + /// Starting year of the applicant's degree + pub user_degree_starting_year: Option, + /// Public status of the application + pub status: ApplicationStatus, + /// Private status of the application (visible only to admins) + pub private_status: ApplicationStatus, +} + +/// Details about a role that has been applied for. +/// +/// Contains information about the role and the user's preference for it. +#[derive(Deserialize, Serialize)] +pub struct ApplicationAppliedRoleDetails { + /// ID of the campaign role + pub campaign_role_id: i64, + /// Name of the role + pub role_name: String, + /// User's preference ranking for this role + pub preference: i32, +} + +/// Data structure for updating role preferences in an application. +#[derive(Deserialize)] +pub struct ApplicationRoleUpdate { + /// Updated list of role preferences + pub roles: Vec, +} + +/// Possible statuses for an application. +/// +/// Applications can be in one of three states: pending review, rejected, or successful. +#[derive(Deserialize, Serialize, sqlx::Type, Clone, Debug)] +#[sqlx(type_name = "application_status", rename_all = "PascalCase")] +pub enum ApplicationStatus { + /// Application is pending review + Pending, + /// Application has been rejected + Rejected, + /// Application has been successful + Successful, +} + +impl Application { + /// Creates a new application in the system. + /// + /// # Arguments + /// + /// * `campaign_id` - ID of the campaign to apply to + /// * `user_id` - ID of the user submitting the application + /// * `application_data` - Details of the application including role preferences + /// * `snowflake_generator` - Generator for creating unique IDs + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn create( + campaign_id: i64, + user_id: i64, + application_data: NewApplication, + snowflake_generator: &mut SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let id = snowflake_generator.real_time_generate(); + + // Insert into table applications + sqlx::query!( + " + INSERT INTO applications (id, campaign_id, user_id) + VALUES ($1, $2, $3) + ", + id, + campaign_id, + user_id + ) + .execute(transaction.deref_mut()) + .await?; + + // Insert into table application_roles + for role_applied in application_data.applied_roles { + sqlx::query!( + " + INSERT INTO application_roles (application_id, campaign_role_id, preference) + VALUES ($1, $2, $3) + ", + id, + role_applied.campaign_role_id, + role_applied.preference + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(id) + } + + /// Retrieves an application by its ID. + /// + /// # Arguments + /// + /// * `id` - ID of the application to retrieve + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - Application details or error + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let application_data = sqlx::query_as!( + ApplicationData, + " + SELECT a.id AS id, campaign_id, user_id, status AS \"status: ApplicationStatus\", + private_status AS \"private_status: ApplicationStatus\", u.email AS user_email, + u.zid AS user_zid, u.name AS user_name, u.gender AS user_gender, + u.pronouns AS user_pronouns, u.degree_name AS user_degree_name, + u.degree_starting_year AS user_degree_starting_year + FROM applications a + JOIN users u ON u.id = a.user_id + JOIN campaigns c ON c.id = a.campaign_id + WHERE a.id = $1 AND a.submitted = true + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let applied_roles = sqlx::query_as!( + ApplicationAppliedRoleDetails, + " + SELECT application_roles.campaign_role_id, + application_roles.preference, campaign_roles.name AS role_name + FROM application_roles + JOIN campaign_roles + ON application_roles.campaign_role_id = campaign_roles.id + WHERE application_id = $1 + ", + id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(ApplicationDetails { + id: application_data.id, + campaign_id: application_data.campaign_id, + status: application_data.status, + private_status: application_data.private_status, + applied_roles, + user: UserDetails { + id: application_data.user_id, + email: application_data.user_email, + zid: application_data.user_zid, + name: application_data.user_name, + pronouns: application_data.user_pronouns, + gender: application_data.user_gender, + degree_name: application_data.user_degree_name, + degree_starting_year: application_data.user_degree_starting_year, + }, + }) + } + + /// Retrieves all applications for a specific role. + /// + /// # Arguments + /// + /// * `role_id` - ID of the role to get applications for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of applications or error + pub async fn get_from_role_id( + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let application_data_list = sqlx::query_as!( + ApplicationData, + " + SELECT a.id AS id, campaign_id, user_id, status AS \"status: ApplicationStatus\", + private_status AS \"private_status: ApplicationStatus\", u.email AS user_email, + u.zid AS user_zid, u.name AS user_name, u.gender AS user_gender, + u.pronouns AS user_pronouns, u.degree_name AS user_degree_name, + u.degree_starting_year AS user_degree_starting_year + FROM applications a + JOIN users u ON u.id = a.user_id + JOIN application_roles ar on ar.application_id = a.id + JOIN campaigns c on c.id = a.campaign_id + WHERE ar.id = $1 AND a.submitted = true + ", + role_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let mut application_details_list = Vec::new(); + for application_data in application_data_list { + let applied_roles = sqlx::query_as!( + ApplicationAppliedRoleDetails, + " + SELECT application_roles.campaign_role_id, + application_roles.preference, campaign_roles.name AS role_name + FROM application_roles + JOIN campaign_roles + ON application_roles.campaign_role_id = campaign_roles.id + WHERE application_id = $1 + ", + application_data.id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let details = ApplicationDetails { + id: application_data.id, + campaign_id: application_data.campaign_id, + status: application_data.status, + private_status: application_data.private_status, + applied_roles, + user: UserDetails { + id: application_data.user_id, + email: application_data.user_email, + zid: application_data.user_zid, + name: application_data.user_name, + pronouns: application_data.user_pronouns, + gender: application_data.user_gender, + degree_name: application_data.user_degree_name, + degree_starting_year: application_data.user_degree_starting_year, + }, + }; + + application_details_list.push(details); + } + + Ok(application_details_list) + } + + /// Retrieves all applications for a specific campaign. + /// + /// # Arguments + /// + /// * `campaign_id` - ID of the campaign to get applications for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of applications or error + pub async fn get_from_campaign_id( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let application_data_list = sqlx::query_as!( + ApplicationData, + " + SELECT a.id AS id, campaign_id, user_id, status AS \"status: ApplicationStatus\", + private_status AS \"private_status: ApplicationStatus\", u.email AS user_email, + u.zid AS user_zid, u.name AS user_name, u.gender AS user_gender, + u.pronouns AS user_pronouns, u.degree_name AS user_degree_name, + u.degree_starting_year AS user_degree_starting_year + FROM applications a + JOIN users u ON u.id = a.user_id + JOIN campaigns c ON c.id = a.campaign_id + WHERE a.campaign_id = $1 AND a.submitted = true + ", + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let mut application_details_list = Vec::new(); + for application_data in application_data_list { + let applied_roles = sqlx::query_as!( + ApplicationAppliedRoleDetails, + " + SELECT application_roles.campaign_role_id, + application_roles.preference, campaign_roles.name AS role_name + FROM application_roles + JOIN campaign_roles + ON application_roles.campaign_role_id = campaign_roles.id + WHERE application_id = $1 + ", + application_data.id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let details = ApplicationDetails { + id: application_data.id, + campaign_id: application_data.campaign_id, + status: application_data.status, + private_status: application_data.private_status, + applied_roles, + user: UserDetails { + id: application_data.user_id, + email: application_data.user_email, + zid: application_data.user_zid, + name: application_data.user_name, + pronouns: application_data.user_pronouns, + gender: application_data.user_gender, + degree_name: application_data.user_degree_name, + degree_starting_year: application_data.user_degree_starting_year, + }, + }; + + application_details_list.push(details) + } + + Ok(application_details_list) + } + + /// Retrieves all applications submitted by a specific user. + /// + /// # Arguments + /// + /// * `user_id` - ID of the user to get applications for + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of applications or error + pub async fn get_from_user_id( + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let application_data_list = sqlx::query_as!( + ApplicationData, + " + SELECT a.id AS id, campaign_id, user_id, status AS \"status: ApplicationStatus\", + private_status AS \"private_status: ApplicationStatus\", u.email AS user_email, + u.zid AS user_zid, u.name AS user_name, u.gender AS user_gender, + u.pronouns AS user_pronouns, u.degree_name AS user_degree_name, + u.degree_starting_year AS user_degree_starting_year + FROM applications a JOIN users u ON u.id = a.user_id + WHERE a.user_id = $1 + ", + user_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let mut application_details_list = Vec::new(); + for application_data in application_data_list { + let applied_roles = sqlx::query_as!( + ApplicationAppliedRoleDetails, + " + SELECT application_roles.campaign_role_id, + application_roles.preference, campaign_roles.name AS role_name + FROM application_roles + JOIN campaign_roles + ON application_roles.campaign_role_id = campaign_roles.id + WHERE application_id = $1 + ", + application_data.id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let details = ApplicationDetails { + id: application_data.id, + campaign_id: application_data.campaign_id, + status: application_data.status.clone(), + // To reuse struct, do not show use private status + private_status: application_data.status, + applied_roles, + user: UserDetails { + id: application_data.user_id, + email: application_data.user_email, + zid: application_data.user_zid, + name: application_data.user_name, + pronouns: application_data.user_pronouns, + gender: application_data.user_gender, + degree_name: application_data.user_degree_name, + degree_starting_year: application_data.user_degree_starting_year, + }, + }; + + application_details_list.push(details) + } + + Ok(application_details_list) + } + + /// Updates the public status of an application. + /// + /// # Arguments + /// + /// * `id` - ID of the application to update + /// * `new_status` - New status to set + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn set_status( + id: i64, + new_status: ApplicationStatus, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( + " + UPDATE applications + SET status = $2 + WHERE id = $1 RETURNING id + ", + id, + new_status as ApplicationStatus + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates the private status of an application. + /// + /// # Arguments + /// + /// * `id` - ID of the application to update + /// * `new_status` - New status to set + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn set_private_status( + id: i64, + new_status: ApplicationStatus, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( + " + UPDATE applications + SET private_status = $2 + WHERE id = $1 RETURNING id + ", + id, + new_status as ApplicationStatus + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates the role preferences for an application. + /// + /// # Arguments + /// + /// * `id` - ID of the application to update + /// * `roles` - New list of role preferences + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_roles( + id: i64, + roles: Vec, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!( + " + DELETE FROM application_roles WHERE application_id = $1 + ", + id + ) + .execute(transaction.deref_mut()) + .await?; + + // Insert into table application_roles + for role in roles { + sqlx::query!( + " + INSERT INTO application_roles (application_id, campaign_role_id, preference) + VALUES ($1, $2, $3) + ", + id, + role.campaign_role_id, + role.preference + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(()) + } + + /// Submits an application, marking it as ready for review. + /// + /// # Arguments + /// + /// * `id` - ID of the application to submit + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn submit( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE applications SET submitted = true WHERE id = $1 RETURNING id + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + +/// Extractor for ensuring an application is open by application ID. +/// +/// This extractor is used in route handlers to ensure that the application +/// being accessed is still open for submissions. +pub struct OpenApplicationByApplicationId; + +#[async_trait] +impl FromRequestParts for OpenApplicationByApplicationId +where + S: Send + Sync, + AppState: FromRef, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + let application_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_application_is_open(application_id, &mut tx).await?; + tx.commit().await?; + + Ok(OpenApplicationByApplicationId) + } +} + +/// Extractor for ensuring an application is open by answer ID. +/// +/// This extractor is used in route handlers to ensure that the application +/// associated with an answer is still open for submissions. +pub struct OpenApplicationByAnswerId; + +#[async_trait] +impl FromRequestParts for OpenApplicationByAnswerId +where + S: Send + Sync, + AppState: FromRef, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + let answer_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_answer_application_is_open(answer_id, &mut tx).await?; + tx.commit().await?; + + Ok(OpenApplicationByAnswerId) + } +} \ No newline at end of file diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs new file mode 100644 index 000000000..16a44e452 --- /dev/null +++ b/backend/server/src/models/auth.rs @@ -0,0 +1,646 @@ +//! Authentication and authorization module for the Chaos application. +//! +//! This module provides functionality for handling user authentication and authorization, +//! including OAuth integration with Google, role-based access control, and various +//! permission checks for different parts of the application. + +use crate::models::app::AppState; +use crate::models::error::ChaosError; +use crate::service::answer::user_is_answer_owner; +use crate::service::application::{user_is_application_admin, user_is_application_owner}; +use crate::service::auth::{assert_is_super_user, extract_user_id_from_request}; +use crate::service::campaign::user_is_campaign_admin; +use crate::service::email_template::user_is_email_template_admin; +use crate::service::offer::{assert_user_is_offer_admin, assert_user_is_offer_recipient}; +use crate::service::organisation::assert_user_is_organisation_admin; +use crate::service::question::user_is_question_admin; +use crate::service::rating::{ + assert_user_is_application_reviewer_given_rating_id, assert_user_is_organisation_member, + assert_user_is_rating_creator_and_organisation_member, +}; +use crate::service::role::user_is_role_admin; +use axum::extract::{FromRef, FromRequestParts, Path}; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::{async_trait, RequestPartsExt}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Request structure for OAuth authentication. +/// +/// Contains the authorization code received from the OAuth provider. +#[derive(Deserialize, Serialize)] +pub struct AuthRequest { + /// Authorization code from the OAuth provider + pub code: String, +} + +/// User profile information received from Google OAuth. +/// +/// Contains basic user information provided by Google after successful authentication. +#[derive(Deserialize, Serialize)] +pub struct GoogleUserProfile { + /// User's full name + pub name: String, + /// User's email address + pub email: String, +} + +/// Response type for authentication redirects. +/// +/// Handles redirecting users to the appropriate authentication page. +pub struct AuthRedirect; + +impl IntoResponse for AuthRedirect { + fn into_response(self) -> Response { + // TODO: Fix this redirect to point to front end login page + Redirect::temporary("/auth/google").into_response() + } +} + +/// Authenticated user information. +/// +/// Contains the user ID of the currently authenticated user. +#[derive(Deserialize, Serialize)] +pub struct AuthUser { + /// ID of the authenticated user + pub user_id: i64, +} + +/// Extractor for authenticated users. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from an authenticated user. +#[async_trait] +impl FromRequestParts for AuthUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + Ok(AuthUser { user_id }) + } +} + +/// Super user information. +/// +/// Contains the user ID of a user with super user privileges. +#[derive(Deserialize, Serialize)] +pub struct SuperUser { + /// ID of the super user + pub user_id: i64, +} + +/// Extractor for super users. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with super user privileges. +#[async_trait] +impl FromRequestParts for SuperUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let mut tx = app_state.db.begin().await?; + assert_is_super_user(user_id, &mut tx).await?; + tx.commit().await?; + + Ok(SuperUser { user_id }) + } +} + +/// Organization administrator information. +/// +/// Contains the user ID of a user with organization administrator privileges. +pub struct OrganisationAdmin { + /// ID of the organization administrator + pub user_id: i64, +} + +/// Extractor for organization administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with organization administrator privileges. +#[async_trait] +impl FromRequestParts for OrganisationAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let organisation_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("organisation_id") + .ok_or(ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_organisation_admin(user_id, organisation_id, &mut tx).await?; + tx.commit().await?; + + Ok(OrganisationAdmin { user_id }) + } +} + +/// Campaign administrator information. +/// +/// Contains the user ID of a user with campaign administrator privileges. +pub struct CampaignAdmin { + /// ID of the campaign administrator + pub user_id: i64, +} + +/// Extractor for campaign administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with campaign administrator privileges. +#[async_trait] +impl FromRequestParts for CampaignAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let campaign_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("campaign_id") + .ok_or(ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_campaign_admin(user_id, campaign_id, &mut tx).await?; + tx.commit().await?; + + Ok(CampaignAdmin { user_id }) + } +} + +/// Role administrator information. +/// +/// Contains the user ID of a user with role administrator privileges. +pub struct RoleAdmin { + /// ID of the role administrator + pub user_id: i64, +} + +/// Extractor for role administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with role administrator privileges. +#[async_trait] +impl FromRequestParts for RoleAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let role_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("role_id") + .ok_or(ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_role_admin(user_id, role_id, &mut tx).await?; + tx.commit().await?; + + Ok(RoleAdmin { user_id }) + } +} + +/// Application administrator information. +/// +/// Contains the user ID of a user with application administrator privileges. +pub struct ApplicationAdmin { + /// ID of the application administrator + pub user_id: i64, +} + +/// Extractor for application administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with application administrator privileges. +#[async_trait] +impl FromRequestParts for ApplicationAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(application_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_application_admin(user_id, application_id, &mut tx).await?; + tx.commit().await?; + + Ok(ApplicationAdmin { user_id }) + } +} + +/// Application reviewer information for a specific application. +/// +/// Contains the user ID of a user who has permission to review a specific application. +pub struct ApplicationReviewerGivenApplicationId { + /// ID of the application reviewer + pub user_id: i64, +} + +/// Extractor for application reviewers. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with permission to review a specific application. +#[async_trait] +impl FromRequestParts for ApplicationReviewerGivenApplicationId +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(application_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_organisation_member(user_id, application_id, &mut tx).await?; + tx.commit().await?; + + Ok(ApplicationReviewerGivenApplicationId { user_id }) + } +} + +/// Application creator information for a specific application. +/// +/// Contains the user ID of a user who created a specific application. +pub struct ApplicationCreatorGivenApplicationId { + /// ID of the application creator + pub user_id: i64, +} + +/// Extractor for application creators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from the creator of a specific application. +#[async_trait] +impl FromRequestParts for ApplicationCreatorGivenApplicationId +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(application_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_application_owner(user_id, application_id, &mut tx).await?; + tx.commit().await; + + Ok(ApplicationCreatorGivenApplicationId { user_id }) + } +} + +/// Application reviewer information for a specific rating. +/// +/// Contains the user ID of a user who has permission to review an application +/// based on a specific rating. +pub struct ApplicationReviewerGivenRatingId { + /// ID of the application reviewer + pub user_id: i64, +} + +/// Extractor for application reviewers based on rating. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with permission to review an application based on a specific rating. +#[async_trait] +impl FromRequestParts for ApplicationReviewerGivenRatingId +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(rating_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_application_reviewer_given_rating_id(user_id, rating_id, &mut tx).await?; + tx.commit().await?; + + Ok(ApplicationReviewerGivenRatingId { user_id }) + } +} + +/// Rating creator information. +/// +/// Contains the user ID of a user who created a specific rating. +pub struct RatingCreator { + /// ID of the rating creator + pub user_id: i64, +} + +/// Extractor for rating creators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from the creator of a specific rating. +#[async_trait] +impl FromRequestParts for RatingCreator +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(rating_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_rating_creator_and_organisation_member(user_id, rating_id, &mut tx).await?; + tx.commit().await?; + + Ok(RatingCreator { user_id }) + } +} + +/// Question administrator information. +/// +/// Contains the user ID of a user with question administrator privileges. +pub struct QuestionAdmin { + /// ID of the question administrator + pub user_id: i64, +} + +/// Extractor for question administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with question administrator privileges. +#[async_trait] +impl FromRequestParts for QuestionAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(question_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_question_admin(user_id, question_id, &mut tx).await?; + tx.commit().await?; + + Ok(QuestionAdmin { user_id }) + } +} + +/// Application owner information. +/// +/// Contains the user ID of a user who owns a specific application. +pub struct ApplicationOwner { + /// ID of the application owner + pub user_id: i64, +} + +/// Extractor for application owners. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from the owner of a specific application. +#[async_trait] +impl FromRequestParts for ApplicationOwner +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(application_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_application_owner(user_id, application_id, &mut tx).await?; + tx.commit().await?; + + Ok(ApplicationOwner { user_id }) + } +} + +/// Answer owner information. +/// +/// Contains the user ID of a user who owns a specific answer. +pub struct AnswerOwner { + /// ID of the answer owner + pub user_id: i64, +} + +/// Extractor for answer owners. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from the owner of a specific answer. +#[async_trait] +impl FromRequestParts for AnswerOwner +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(answer_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_answer_owner(user_id, answer_id, &mut tx).await?; + tx.commit().await?; + + Ok(AnswerOwner { user_id }) + } +} + +/// Email template administrator information. +/// +/// Contains the user ID of a user with email template administrator privileges. +pub struct EmailTemplateAdmin { + /// ID of the email template administrator + pub user_id: i64, +} + +/// Extractor for email template administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with email template administrator privileges. +#[async_trait] +impl FromRequestParts for EmailTemplateAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(email_template_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + user_is_email_template_admin(user_id, email_template_id, &mut tx).await?; + tx.commit().await?; + + Ok(EmailTemplateAdmin { user_id }) + } +} + +/// Offer administrator information. +/// +/// Contains the user ID of a user with offer administrator privileges. +pub struct OfferAdmin { + /// ID of the offer administrator + pub user_id: i64, +} + +/// Extractor for offer administrators. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from a user with offer administrator privileges. +#[async_trait] +impl FromRequestParts for OfferAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(offer_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_offer_admin(user_id, offer_id, &mut tx).await?; + tx.commit().await?; + + Ok(OfferAdmin { user_id }) + } +} + +/// Offer recipient information. +/// +/// Contains the user ID of a user who is the recipient of a specific offer. +pub struct OfferRecipient { + /// ID of the offer recipient + pub user_id: i64, +} + +/// Extractor for offer recipients. +/// +/// This extractor is used in route handlers to ensure that the request +/// comes from the recipient of a specific offer. +#[async_trait] +impl FromRequestParts for OfferRecipient +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let Path(offer_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_user_is_offer_recipient(user_id, offer_id, &mut tx).await?; + tx.commit().await?; + + Ok(OfferRecipient { user_id }) + } +} diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs new file mode 100644 index 000000000..446a63d0a --- /dev/null +++ b/backend/server/src/models/campaign.rs @@ -0,0 +1,404 @@ +//! Campaign management module for the Chaos application. +//! +//! This module provides functionality for managing recruitment campaigns, +//! including creation, updates, and retrieval of campaign information. +//! It also handles campaign banner management and campaign status tracking. + +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use s3::Bucket; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Transaction}; +use sqlx::{Pool, Postgres}; +use std::ops::DerefMut; +use axum::{async_trait, RequestPartsExt}; +use axum::extract::{FromRef, FromRequestParts, Path}; +use axum::http::request::Parts; +use uuid::Uuid; +use crate::models::app::AppState; +use crate::service::campaign::assert_campaign_is_open; +use super::{error::ChaosError, storage::Storage}; + +/// Represents a campaign in the system. +/// +/// A campaign is a recruitment drive organized by an organization, with a specific +/// time period and set of roles to fill. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Campaign { + /// Unique identifier for the campaign + pub id: i64, + /// URL-friendly identifier for the campaign + pub slug: String, + /// Display name of the campaign + pub name: String, + /// ID of the organization running the campaign + pub organisation_id: i64, + /// Name of the organization running the campaign + pub organisation_name: String, + /// Optional UUID of the campaign's cover image + pub cover_image: Option, + /// Optional description of the campaign + pub description: Option, + /// When the campaign starts accepting applications + pub starts_at: DateTime, + /// When the campaign stops accepting applications + pub ends_at: DateTime, + /// When the campaign was created + pub created_at: DateTime, + /// When the campaign was last updated + pub updated_at: DateTime, +} + +/// Detailed view of a campaign. +/// +/// Contains additional information about the campaign and its organization. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct CampaignDetails { + /// Unique identifier for the campaign + pub id: i64, + /// URL-friendly identifier for the campaign + pub campaign_slug: String, + /// Display name of the campaign + pub name: String, + /// ID of the organization running the campaign + pub organisation_id: i64, + /// URL-friendly identifier for the organization + pub organisation_slug: String, + /// Name of the organization running the campaign + pub organisation_name: String, + /// Optional UUID of the campaign's cover image + pub cover_image: Option, + /// Optional description of the campaign + pub description: Option, + /// When the campaign starts accepting applications + pub starts_at: DateTime, + /// When the campaign stops accepting applications + pub ends_at: DateTime, +} + +/// Simplified view of a campaign for organization listings. +/// +/// Contains only the essential information needed when displaying campaigns +/// within an organization's context. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct OrganisationCampaign { + /// Unique identifier for the campaign + pub id: i64, + /// URL-friendly identifier for the campaign + pub slug: String, + /// Display name of the campaign + pub name: String, + /// Optional UUID of the campaign's cover image + pub cover_image: Option, + /// Optional description of the campaign + pub description: Option, + /// When the campaign starts accepting applications + pub starts_at: DateTime, + /// When the campaign stops accepting applications + pub ends_at: DateTime, +} + +/// Data structure for creating a new campaign. +/// +/// Contains all the information needed to create a new campaign. +#[derive(Deserialize)] +pub struct NewCampaign { + /// URL-friendly identifier for the campaign + pub slug: String, + /// Display name of the campaign + pub name: String, + /// Optional description of the campaign + pub description: Option, + /// When the campaign starts accepting applications + pub starts_at: DateTime, + /// When the campaign stops accepting applications + pub ends_at: DateTime +} + +/// Data structure for updating an existing campaign. +/// +/// Contains all the fields that can be updated for a campaign. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct CampaignUpdate { + /// URL-friendly identifier for the campaign + pub slug: String, + /// Display name of the campaign + pub name: String, + /// Description of the campaign + pub description: String, + /// When the campaign starts accepting applications + pub starts_at: DateTime, + /// When the campaign stops accepting applications + pub ends_at: DateTime, +} + +/// Response structure for campaign banner updates. +/// +/// Contains the URL where the new banner image can be uploaded. +#[derive(Serialize)] +pub struct CampaignBannerUpdate { + /// URL where the new banner image can be uploaded + pub upload_url: String, +} + +impl Campaign { + /// Retrieves all campaigns in the system. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result, ChaosError>` - List of campaigns or error + pub async fn get_all( + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let campaigns = sqlx::query_as!( + Campaign, + " + SELECT c.*, o.name as organisation_name FROM campaigns c + JOIN organisations o on c.organisation_id = o.id + " + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(campaigns) + } + + /// Retrieves a campaign by its ID. + /// + /// # Arguments + /// + /// * `id` - ID of the campaign to retrieve + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - Campaign details or error + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let campaign = sqlx::query_as!( + CampaignDetails, + " + SELECT c.id, c.slug AS campaign_slug, c.name, c.organisation_id, + o.slug AS organisation_slug, o.name as organisation_name, c.cover_image, + c.description, c.starts_at, c.ends_at + FROM campaigns c + JOIN organisations o on c.organisation_id = o.id + WHERE c.id = $1 + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(campaign) + } + + /// Checks if a slug is available for a new campaign. + /// + /// # Arguments + /// + /// * `organisation_id` - ID of the organization creating the campaign + /// * `slug` - Slug to check + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success if slug is available, error if not + pub async fn check_slug_availability( + organisation_id: i64, + slug: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM campaigns WHERE organisation_id = $1 AND slug = $2) + ", + organisation_id, + slug + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if exists { + return Err(ChaosError::BadRequest); + } + + Ok(()) + } + + /// Retrieves a campaign by its organization and campaign slugs. + /// + /// # Arguments + /// + /// * `organisation_slug` - Slug of the organization + /// * `campaign_slug` - Slug of the campaign + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - Campaign details or error + pub async fn get_by_slugs( + organisation_slug: String, + campaign_slug: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let campaign = sqlx::query_as!( + CampaignDetails, + " + SELECT c.id, c.slug AS campaign_slug, c.name, c.organisation_id, + o.slug AS organisation_slug, o.name as organisation_name, c.cover_image, + c.description, c.starts_at, c.ends_at + FROM campaigns c + JOIN organisations o on c.organisation_id = o.id + WHERE c.slug = $1 AND o.slug = $2 + ", + campaign_slug, + organisation_slug + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(campaign) + } + + /// Updates an existing campaign. + /// + /// # Arguments + /// + /// * `id` - ID of the campaign to update + /// * `update` - New campaign data + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update( + id: i64, + update: CampaignUpdate, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( + " + UPDATE campaigns + SET slug = $1, name = $2, description = $3, starts_at = $4, ends_at = $5 + WHERE id = $6 RETURNING id + ", + update.slug, + update.name, + update.description, + update.starts_at, + update.ends_at, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates a campaign's banner image. + /// + /// # Arguments + /// + /// * `id` - ID of the campaign to update + /// * `transaction` - Database transaction to use + /// * `storage_bucket` - S3 bucket for storing the image + /// + /// # Returns + /// + /// * `Result` - Upload URL or error + pub async fn update_banner( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + storage_bucket: &Bucket, + ) -> Result { + let dt = Utc::now(); + let image_id = Uuid::new_v4(); + let current_time = dt; + + _ = sqlx::query!( + " + UPDATE campaigns + SET cover_image = $1, updated_at = $2 + WHERE id = $3 RETURNING id + ", + image_id, + current_time, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let upload_url = + Storage::generate_put_url(format!("/banner/{id}/{image_id}"), storage_bucket).await?; + + Ok(CampaignBannerUpdate { upload_url }) + } + + /// Deletes a campaign. + /// + /// # Arguments + /// + /// * `id` - ID of the campaign to delete + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( + " + DELETE FROM campaigns WHERE id = $1 RETURNING id + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + +/// Extractor for ensuring a campaign is open. +/// +/// This extractor is used in route handlers to ensure that the campaign +/// being accessed is currently accepting applications. +pub struct OpenCampaign; + +#[async_trait] +impl FromRequestParts for OpenCampaign +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let Path(campaign_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + let mut tx = app_state.db.begin().await?; + assert_campaign_is_open(campaign_id, &mut tx).await?; + tx.commit().await?; + + Ok(OpenCampaign) + } +} diff --git a/backend/server/src/models/email.rs b/backend/server/src/models/email.rs new file mode 100644 index 000000000..51186c950 --- /dev/null +++ b/backend/server/src/models/email.rs @@ -0,0 +1,126 @@ +//! Email functionality for Chaos. +//! +//! This module provides functionality for sending emails using SMTP. +//! It handles email credentials management and message sending through +//! the Lettre email library. + +use crate::models::error::ChaosError; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use serde::Serialize; +use std::env; + +/// Main email service for Chaos. +/// +/// This struct provides methods for setting up email credentials and +/// sending emails through SMTP. +pub struct ChaosEmail; + +/// Email credentials and configuration. +/// +/// This struct holds the SMTP credentials and host information needed +/// to establish email connections. +#[derive(Clone)] +pub struct EmailCredentials { + /// SMTP authentication credentials + pub credentials: Credentials, + /// SMTP server host address + pub email_host: String, +} + +/// Components of an email message. +/// +/// This struct represents the subject and body of an email message, +/// which can be serialized to JSON. +#[derive(Serialize)] +pub struct EmailParts { + /// The email subject line + pub subject: String, + /// The email body content + pub body: String, +} + +impl ChaosEmail { + /// Sets up email credentials from environment variables. + /// + /// # Environment Variables + /// * `SMTP_USERNAME` - The SMTP username + /// * `SMTP_PASSWORD` - The SMTP password + /// * `SMTP_HOST` - The SMTP server host + /// + /// # Returns + /// Returns an `EmailCredentials` instance with the configured credentials. + /// + /// # Panics + /// Panics if any of the required environment variables are not set. + pub fn setup_credentials() -> EmailCredentials { + let smtp_username = env::var("SMTP_USERNAME") + .expect("Error getting SMTP USERNAME") + .to_string(); + + let smtp_password = env::var("SMTP_PASSWORD") + .expect("Error getting SMTP PASSWORD") + .to_string(); + + let email_host = env::var("SMTP_HOST") + .expect("Error getting SMTP HOST") + .to_string(); + + EmailCredentials { + credentials: Credentials::new(smtp_username, smtp_password), + email_host, + } + } + + /// Creates a new SMTP connection with the provided credentials. + /// + /// # Arguments + /// * `credentials` - The email credentials to use for the connection + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(AsyncSmtpTransport)` - A configured SMTP transport + /// * `Err(ChaosError)` - An error if connection setup fails + fn new_connection( + credentials: EmailCredentials, + ) -> Result, ChaosError> { + Ok( + AsyncSmtpTransport::::relay(&credentials.email_host)? + .credentials(credentials.credentials) + .build(), + ) + } + + /// Sends an email message. + /// + /// # Arguments + /// * `recipient_name` - The name of the email recipient + /// * `recipient_email_address` - The email address of the recipient + /// * `subject` - The email subject + /// * `body` - The email body content + /// * `credentials` - The email credentials to use for sending + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the email was sent successfully + /// * `Err(ChaosError)` - An error if sending fails + pub async fn send_message( + recipient_name: String, + recipient_email_address: String, + subject: String, + body: String, + credentials: EmailCredentials, + ) -> Result<(), ChaosError> { + let message = Message::builder() + .from("Chaos Subcommittee Recruitment ".parse()?) + .reply_to("help@chaos.devsoc.app".parse()?) + .to(format!("{recipient_name} <{recipient_email_address}>").parse()?) + .subject(subject) + .body(body)?; + + let mailer = Self::new_connection(credentials)?; + mailer.send(message).await?; + + Ok(()) + } +} diff --git a/backend/server/src/models/email_template.rs b/backend/server/src/models/email_template.rs new file mode 100644 index 000000000..2a8335269 --- /dev/null +++ b/backend/server/src/models/email_template.rs @@ -0,0 +1,203 @@ +//! Email template management for Chaos. +//! +//! This module provides functionality for managing email templates with support +//! for variable substitution using Handlebars templating. + +use crate::models::email::EmailParts; +use crate::models::error::ChaosError; +use chrono::{DateTime, Local, Utc}; +use handlebars::Handlebars; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres, Transaction}; +use std::collections::HashMap; +use std::ops::DerefMut; + +/// Represents an email template in the database. +/// +/// Email templates support the following variables in their subject and body: +/// * `name` - The recipient's name +/// * `role` - The role being applied for +/// * `organisation_name` - The name of the organisation +/// * `expiry_date` - The expiration date of the application/offer +/// * `campaign_name` - The name of the recruitment campaign +#[derive(Deserialize, Serialize)] +pub struct EmailTemplate { + /// Unique identifier for the template + pub id: i64, + /// ID of the organisation that owns this template + pub organisation_id: i64, + /// Display name of the template + pub name: String, + /// Template for the email subject line + pub template_subject: String, + /// Template for the email body content + pub template_body: String, +} + +/// Data structure for creating a new email template. +/// +/// This struct contains the fields needed to create a new email template, +/// excluding the ID and organisation ID which are managed by the system. +#[derive(Deserialize, Serialize)] +pub struct NewEmailTemplate { + /// Display name of the template + pub name: String, + /// Template for the email subject line + pub template_subject: String, + /// Template for the email body content + pub template_body: String, +} + +impl EmailTemplate { + /// Retrieves an email template by its ID. + /// + /// # Arguments + /// * `id` - The ID of the template to retrieve + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(EmailTemplate)` - The requested template + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let template = sqlx::query_as!( + EmailTemplate, + "SELECT * FROM email_templates WHERE id = $1", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(template) + } + + /// Retrieves all email templates for a specific organisation. + /// + /// # Arguments + /// * `organisation_id` - The ID of the organisation + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(Vec)` - List of templates for the organisation + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_all_by_organisation( + organisation_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let templates = sqlx::query_as!( + EmailTemplate, + "SELECT * FROM email_templates WHERE organisation_id = $1", + organisation_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(templates) + } + + /// Updates an existing email template. + /// + /// # Arguments + /// * `id` - The ID of the template to update + /// * `name` - The new name for the template + /// * `template_subject` - The new subject template + /// * `template_body` - The new body template + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the update was successful + /// * `Err(ChaosError)` - An error if the update fails + pub async fn update( + id: i64, + name: String, + template_subject: String, + template_body: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE email_templates SET name = $2, template_subject = $3, template_body = $4 WHERE id = $1 RETURNING id + ", + id, + name, + template_subject, + template_body + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Deletes an email template. + /// + /// # Arguments + /// * `id` - The ID of the template to delete + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the deletion was successful + /// * `Err(ChaosError)` - An error if the deletion fails + pub async fn delete(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result<(), ChaosError> { + let _ = sqlx::query!("DELETE FROM email_templates WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Generates an email using a template and provided data. + /// + /// # Arguments + /// * `name` - The recipient's name + /// * `role` - The role being applied for + /// * `organisation_name` - The name of the organisation + /// * `campaign_name` - The name of the recruitment campaign + /// * `expiry_date` - The expiration date of the application/offer + /// * `email_template_id` - The ID of the template to use + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(EmailParts)` - The generated email subject and body + /// * `Err(ChaosError)` - An error if generation fails + pub async fn generate_email( + name: String, + role: String, + organisation_name: String, + campaign_name: String, + expiry_date: DateTime, + email_template_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let template = EmailTemplate::get(email_template_id, transaction).await?; + + let mut handlebars = Handlebars::new(); + handlebars.register_template_string("template_subject", template.template_subject)?; + handlebars.register_template_string("template_body", template.template_body)?; + + let mut data = HashMap::new(); + data.insert("name", name); + data.insert("role", role); + data.insert("organisation_name", organisation_name); + data.insert("campaign_name", campaign_name); + data.insert( + "expiry_date", + expiry_date + .with_timezone(&Local) + .format("%d/%m/%Y %H:%M") + .to_string(), + ); + + let subject = handlebars.render("template_subject", &data)?; + let body = handlebars.render("template_body", &data)?; + + Ok(EmailParts { subject, body }) + } +} diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs new file mode 100644 index 000000000..4fbacd1db --- /dev/null +++ b/backend/server/src/models/error.rs @@ -0,0 +1,113 @@ +//! Error handling module for the Chaos application. +//! +//! This module defines the core error types and their conversion to HTTP responses. +//! It provides a unified error handling system that covers both application-specific +//! errors and errors from external dependencies. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; + +/// Custom error enum for Chaos. +/// +/// Handles all errors thrown by libraries (when `?` is used) alongside +/// specific errors for business logic. Each variant represents a different +/// type of error that can occur in the application, from authentication +/// failures to database errors. +#[derive(thiserror::Error, Debug)] +pub enum ChaosError { + /// User is not authenticated + #[error("Not logged in")] + NotLoggedIn, + + /// User is authenticated but not authorized for the operation + #[error("Not authorized")] + Unauthorized, + + /// Operation is forbidden for the current user + #[error("Forbidden operation")] + ForbiddenOperation, + + /// Invalid request parameters or data + #[error("Bad request")] + BadRequest, + + /// Application period has ended + #[error("Application closed")] + ApplicationClosed, + + /// Campaign period has ended + #[error("Campagin closed")] + CampaignClosed, + + /// Database operation failed + #[error("SQLx error")] + DatabaseError(#[from] sqlx::Error), + + /// HTTP request failed + #[error("Reqwest error")] + ReqwestError(#[from] reqwest::Error), + + /// OAuth2 authentication failed + #[error("OAuth2 error")] + OAuthError( + #[from] + oauth2::RequestTokenError< + oauth2::reqwest::Error, + oauth2::StandardErrorResponse, + >, + ), + + /// S3 storage operation failed + #[error("S3 error")] + StorageError(#[from] s3::error::S3Error), + + /// Environment variable loading failed + #[error("DotEnvy error")] + DotEnvyError(#[from] dotenvy::Error), + + /// Template parsing failed + #[error("Templating error")] + TemplateError(#[from] handlebars::TemplateError), + + /// Template rendering failed + #[error("Template rendering error")] + TemplateRendorError(#[from] handlebars::RenderError), + + /// Email sending failed + #[error("Lettre error")] + LettreError(#[from] lettre::error::Error), + + /// Invalid email address + #[error("Email address error")] + AddressError(#[from] lettre::address::AddressError), + + /// SMTP transport failed + #[error("SMTP transport error")] + SmtpTransportError(#[from] lettre::transport::smtp::Error), +} + +/// Implementation for converting errors into HTTP responses. +/// +/// This implementation maps each error type to an appropriate HTTP status code +/// and response format. It handles both application-specific errors and +/// errors from external dependencies. +impl IntoResponse for ChaosError { + fn into_response(self) -> Response { + match self { + ChaosError::NotLoggedIn => Redirect::temporary("/auth/google").into_response(), + ChaosError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + ChaosError::ForbiddenOperation => { + (StatusCode::FORBIDDEN, "Forbidden operation").into_response() + } + ChaosError::BadRequest => (StatusCode::BAD_REQUEST, "Bad request").into_response(), + ChaosError::ApplicationClosed => (StatusCode::BAD_REQUEST, "Application closed").into_response(), + ChaosError::CampaignClosed => (StatusCode::BAD_REQUEST, "Campaign closed").into_response(), + ChaosError::DatabaseError(db_error) => match db_error { + // We only care about the RowNotFound error, as others are miscellaneous DB errors. + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), + }, + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), + } + } +} diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs new file mode 100644 index 000000000..bdb80789e --- /dev/null +++ b/backend/server/src/models/mod.rs @@ -0,0 +1,25 @@ +//! Core data models for the Chaos application. +//! +//! This module contains all the data structures and types that represent the core entities +//! in the Chaos system. Each submodule represents a distinct domain model with its associated +//! functionality. +//! +//! The models are designed to be used with the application's database layer and API endpoints, +//! providing a consistent interface for data manipulation and validation. + +pub mod answer; +pub mod app; +pub mod application; +pub mod auth; +pub mod campaign; +pub mod email; +pub mod email_template; +pub mod error; +pub mod offer; +pub mod organisation; +pub mod question; +pub mod rating; +pub mod role; +pub mod storage; +pub mod transaction; +pub mod user; diff --git a/backend/server/src/models/offer.rs b/backend/server/src/models/offer.rs new file mode 100644 index 000000000..bd4a70f99 --- /dev/null +++ b/backend/server/src/models/offer.rs @@ -0,0 +1,361 @@ +//! Offer management for Chaos. +//! +//! This module provides functionality for managing job offers in recruitment campaigns, +//! including creation, updates, and email notifications. + +use crate::models::email::{ChaosEmail, EmailCredentials, EmailParts}; +use crate::models::email_template::EmailTemplate; +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents an offer in the database. +/// +/// An offer represents a job offer made to an applicant, including +/// the associated role, campaign, and email template for notification. +#[derive(Deserialize)] +pub struct Offer { + /// Unique identifier for the offer + pub id: i64, + /// ID of the campaign this offer belongs to + pub campaign_id: i64, + /// ID of the application this offer is for + pub application_id: i64, + /// ID of the email template to use for notifications + pub email_template_id: i64, + /// ID of the role being offered + pub role_id: i64, + /// When the offer expires + pub expiry: DateTime, + /// Current status of the offer + pub status: OfferStatus, + /// When the offer was created + pub created_at: DateTime, +} + +/// Detailed view of an offer's information. +/// +/// This struct provides a complete view of an offer's details, +/// including related information from other tables. +#[derive(Deserialize, Serialize)] +pub struct OfferDetails { + /// Unique identifier for the offer + pub id: i64, + /// ID of the campaign this offer belongs to + pub campaign_id: i64, + /// Name of the organisation making the offer + pub organisation_name: String, + /// Name of the campaign + pub campaign_name: String, + /// ID of the application this offer is for + pub application_id: i64, + /// ID of the user receiving the offer + pub user_id: i64, + /// Name of the user receiving the offer + pub user_name: String, + /// Email address of the user receiving the offer + pub user_email: String, + /// ID of the email template to use for notifications + pub email_template_id: i64, + /// ID of the role being offered + pub role_id: i64, + /// Name of the role being offered + pub role_name: String, + /// When the offer expires + pub expiry: DateTime, + /// Current status of the offer + pub status: OfferStatus, + /// When the offer was created + pub created_at: DateTime, +} + +/// Possible states of an offer. +/// +/// This enum represents the different states an offer can be in, +/// from initial creation to final acceptance or rejection. +#[derive(Deserialize, Serialize, sqlx::Type, Clone, Debug)] +#[sqlx(type_name = "offer_status", rename_all = "PascalCase")] +pub enum OfferStatus { + /// Offer is in draft state and hasn't been sent + Draft, + /// Offer has been sent to the applicant + Sent, + /// Offer has been accepted by the applicant + Accepted, + /// Offer has been declined by the applicant + Declined, +} + +/// Response to an offer. +/// +/// This struct represents an applicant's response to an offer, +/// indicating whether they accept or decline. +#[derive(Deserialize)] +pub struct OfferReply { + /// Whether the offer is accepted + pub accept: bool, +} + +impl Offer { + /// Creates a new offer. + /// + /// # Arguments + /// * `campaign_id` - The ID of the campaign + /// * `application_id` - The ID of the application + /// * `email_template_id` - The ID of the email template to use + /// * `role_id` - The ID of the role being offered + /// * `expiry` - When the offer expires + /// * `transaction` - A mutable reference to the database transaction + /// * `snowflake_id_generator` - A generator for creating unique IDs + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(i64)` - The ID of the created offer + /// * `Err(ChaosError)` - An error if creation fails + pub async fn create( + campaign_id: i64, + application_id: i64, + email_template_id: i64, + role_id: i64, + expiry: DateTime, + transaction: &mut Transaction<'_, Postgres>, + snowflake_id_generator: &mut SnowflakeIdGenerator, + ) -> Result { + let id = snowflake_id_generator.real_time_generate(); + + let _ = sqlx::query!( + " + INSERT INTO offers (id, campaign_id, application_id, email_template_id, role_id, expiry) VALUES ($1, $2, $3, $4, $5, $6) + ", + id, + campaign_id, + application_id, + email_template_id, + role_id, + expiry + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(id) + } + + /// Retrieves an offer by its ID. + /// + /// # Arguments + /// * `id` - The ID of the offer to retrieve + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(OfferDetails)` - The requested offer details + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let offer = sqlx::query_as!( + OfferDetails, + r#" + SELECT + off.id, off.campaign_id, off.application_id, off.email_template_id, + off.role_id, off.expiry, off.created_at, + off.status AS "status!: OfferStatus", + c.name as campaign_name, + o.name as organisation_name, + u.id as user_id, + u.name as user_name, + u.email as user_email, + r.name as role_name + FROM offers off + JOIN campaigns c ON c.id = off.campaign_id + JOIN organisations o ON o.id = c.organisation_id + JOIN applications a ON a.id = off.application_id + JOIN users u ON u.id = a.user_id + JOIN campaign_roles r ON r.id = off.role_id + WHERE off.id = $1 + "#, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(offer) + } + + /// Retrieves all offers for a specific campaign. + /// + /// # Arguments + /// * `campaign_id` - The ID of the campaign to get offers from + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(Vec)` - List of offers in the campaign + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let offers = sqlx::query_as!( + OfferDetails, + r#" + SELECT + off.id, off.campaign_id, off.application_id, off.email_template_id, + off.role_id, off.expiry, off.created_at, + off.status AS "status!: OfferStatus", + c.name as campaign_name, + o.name as organisation_name, + u.id as user_id, + u.name as user_name, + u.email as user_email, + r.name as role_name + FROM offers off + JOIN campaigns c on c.id = off.campaign_id + JOIN organisations o on o.id = c.organisation_id + JOIN applications a ON a.id = off.application_id + JOIN users u on u.id = a.user_id + JOIN campaign_roles r on r.id = off.role_id + WHERE off.campaign_id = $1 + "#, + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(offers) + } + + /// Deletes an offer. + /// + /// # Arguments + /// * `id` - The ID of the offer to delete + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the offer was deleted successfully + /// * `Err(ChaosError)` - An error if deletion fails + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!("DELETE FROM offers WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Processes a response to an offer. + /// + /// # Arguments + /// * `id` - The ID of the offer to respond to + /// * `accept` - Whether the offer is being accepted + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the response was processed successfully + /// * `Err(ChaosError)` - An error if processing fails + /// + /// # Note + /// This will fail if the offer has expired. + pub async fn reply( + id: i64, + accept: bool, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let offer = Offer::get(id, transaction).await?; + + if Utc::now() > offer.expiry { + return Err(ChaosError::BadRequest); + } + + let mut status = OfferStatus::Accepted; + if !accept { + status = OfferStatus::Declined; + } + + let _ = sqlx::query!( + "UPDATE offers SET status = $2 WHERE id = $1", + id, + status as OfferStatus + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Generates a preview of the offer email. + /// + /// # Arguments + /// * `id` - The ID of the offer to preview + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(EmailParts)` - The generated email subject and body + /// * `Err(ChaosError)` - An error if generation fails + pub async fn preview_email( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let offer = Offer::get(id, transaction).await?; + let email_parts = EmailTemplate::generate_email( + offer.user_name, + offer.role_name, + offer.organisation_name, + offer.campaign_name, + offer.expiry, + offer.email_template_id, + transaction, + ) + .await?; + + Ok(email_parts) + } + + /// Sends an offer email to the applicant. + /// + /// # Arguments + /// * `id` - The ID of the offer to send + /// * `transaction` - A mutable reference to the database transaction + /// * `email_credentials` - The credentials to use for sending email + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the email was sent successfully + /// * `Err(ChaosError)` - An error if sending fails + pub async fn send_offer( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + email_credentials: EmailCredentials, + ) -> Result<(), ChaosError> { + let offer = Offer::get(id, transaction).await?; + let email_parts = EmailTemplate::generate_email( + offer.user_name.clone(), + offer.role_name, + offer.organisation_name, + offer.campaign_name, + offer.expiry, + offer.email_template_id, + transaction, + ) + .await?; + + ChaosEmail::send_message( + offer.user_name, + offer.user_email, + email_parts.subject, + email_parts.body, + email_credentials, + ) + .await?; + Ok(()) + } +} diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs new file mode 100644 index 000000000..4b1fe0259 --- /dev/null +++ b/backend/server/src/models/organisation.rs @@ -0,0 +1,654 @@ +//! Organisation management for Chaos. +//! +//! This module provides functionality for managing organisations in the system, +//! including creation, updates, member management, and campaign creation. + +use crate::models::campaign::OrganisationCampaign; +use crate::models::error::ChaosError; +use crate::models::storage::Storage; +use chrono::{DateTime, Utc}; +use s3::Bucket; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; +use uuid::Uuid; + +/// Represents an organisation in the database. +/// +/// An organisation is a group that can run recruitment campaigns +/// and manage its members and administrators. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Organisation { + /// Unique identifier for the organisation + pub id: i64, + /// URL-friendly identifier for the organisation + pub slug: String, + /// Display name of the organisation + pub name: String, + /// Optional UUID of the organisation's logo + pub logo: Option, + /// When the organisation was created + pub created_at: DateTime, + /// When the organisation was last updated + pub updated_at: DateTime, + /// List of campaigns run by this organisation + pub campaigns: Vec, // Awaiting Campaign to be complete - remove comment once done + /// List of user IDs who are administrators of this organisation + pub organisation_admins: Vec, +} + +/// Data structure for creating a new organisation. +/// +/// This struct contains the fields needed to create a new organisation, +/// including the initial administrator. +#[derive(Deserialize, Serialize)] +pub struct NewOrganisation { + /// URL-friendly identifier for the organisation + pub slug: String, + /// Display name of the organisation + pub name: String, + /// ID of the user who will be the initial administrator + pub admin: i64, +} + +/// Detailed view of an organisation's information. +/// +/// This struct provides a complete view of an organisation's details, +/// used primarily for API responses. +#[derive(Deserialize, Serialize)] +pub struct OrganisationDetails { + /// Unique identifier for the organisation + pub id: i64, + /// URL-friendly identifier for the organisation + pub slug: String, + /// Display name of the organisation + pub name: String, + /// Optional UUID of the organisation's logo + pub logo: Option, + /// When the organisation was created + pub created_at: DateTime, +} + +/// Possible roles for organisation members. +/// +/// This enum represents the different roles a user can have +/// within an organisation. +#[derive(Deserialize, Serialize, sqlx::Type, Clone)] +#[sqlx(type_name = "organisation_role", rename_all = "PascalCase")] +pub enum OrganisationRole { + /// Regular member with basic access + User, + /// Administrator with full access + Admin, +} + +/// Represents a member of an organisation. +/// +/// This struct contains information about a user's membership +/// in an organisation, including their role. +#[derive(Deserialize, Serialize, FromRow)] +pub struct Member { + /// ID of the user + pub id: i64, + /// Name of the user + pub name: String, + /// User's role in the organisation + pub role: OrganisationRole, +} + +/// Collection of organisation members. +/// +/// This struct represents all members of an organisation, +/// used for API responses. +#[derive(Deserialize, Serialize)] +pub struct MemberList { + /// List of members in the organisation + pub members: Vec, +} + +/// Data structure for updating organisation administrators. +/// +/// This struct contains a list of user IDs who should be +/// administrators of the organisation. +#[derive(Deserialize, Serialize)] +pub struct AdminUpdateList { + /// List of user IDs to be administrators + pub members: Vec, +} + +/// Data structure for removing an administrator. +/// +/// This struct contains the ID of the user to remove +/// from the administrator role. +#[derive(Deserialize, Serialize)] +pub struct AdminToRemove { + /// ID of the user to remove as administrator + pub user_id: i64, +} + +/// Data structure for checking slug availability. +/// +/// This struct contains a slug to check for availability +/// when creating a new organisation. +#[derive(Deserialize)] +pub struct SlugCheck { + /// The slug to check + pub slug: String, +} + +impl Organisation { + /// Creates a new organisation. + /// + /// # Arguments + /// * `admin_id` - The ID of the user who will be the initial administrator + /// * `slug` - The URL-friendly identifier for the organisation + /// * `name` - The display name of the organisation + /// * `snowflake_generator` - A generator for creating unique IDs + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the organisation was created successfully + /// * `Err(ChaosError)` - An error if creation fails + /// + /// # Note + /// The slug must be ASCII-only. + pub async fn create( + admin_id: i64, + slug: String, + name: String, + snowflake_generator: &mut SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let id = snowflake_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO organisations (id, slug, name) + VALUES ($1, $2, $3) + ", + id, + slug.to_lowercase(), + name + ) + .execute(transaction.deref_mut()) + .await?; + + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + id, + admin_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(id) + } + + /// Checks if a slug is available for use. + /// + /// # Arguments + /// * `slug` - The slug to check + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the slug is available + /// * `Err(ChaosError)` - If the slug is invalid or already in use + /// + /// # Note + /// The slug must be ASCII-only. + pub async fn check_slug_availability( + slug: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organisations WHERE slug = $1) + ", + slug + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if exists { + return Err(ChaosError::BadRequest); + } + + Ok(()) + } + + /// Retrieves an organisation by its ID. + /// + /// # Arguments + /// * `id` - The ID of the organisation to retrieve + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(OrganisationDetails)` - The requested organisation details + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result { + let organisation = sqlx::query_as!( + OrganisationDetails, + " + SELECT id, slug, name, logo, created_at + FROM organisations + WHERE id = $1 + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(organisation) + } + + /// Retrieves an organisation by its slug. + /// + /// # Arguments + /// * `slug` - The slug of the organisation to retrieve + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(OrganisationDetails)` - The requested organisation details + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_by_slug( + slug: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let organisation = sqlx::query_as!( + OrganisationDetails, + " + SELECT id, slug, name, logo, created_at + FROM organisations + WHERE slug = $1 + ", + slug + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(organisation) + } + + pub async fn get_by_admin( + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let orgs = sqlx::query_as!( + OrganisationDetails, + " + SELECT o.id, o.slug, o.name, o.logo, o.created_at + FROM organisations o + JOIN organisation_members om + ON o.id = om.organisation_id + WHERE om.user_id = $1 + AND om.role = 'Admin' + ", + user_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(orgs) + } + + /// Deletes an organisation. + /// + /// # Arguments + /// * `id` - The ID of the organisation to delete + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the organisation was deleted successfully + /// * `Err(ChaosError)` - An error if deletion fails + pub async fn delete(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result<(), ChaosError> { + _ = sqlx::query!( + " + DELETE FROM organisations WHERE id = $1 RETURNING id + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Retrieves all administrators of an organisation. + /// + /// # Arguments + /// * `organisation_id` - The ID of the organisation + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(MemberList)` - List of administrators + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_admins( + organisation_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let admin_list = sqlx::query_as!( + Member, + " + SELECT organisation_members.user_id as id, organisation_members.role AS \"role: OrganisationRole\", users.name from organisation_members + JOIN users on users.id = organisation_members.user_id + WHERE organisation_members.organisation_id = $1 AND organisation_members.role = $2 + ", + organisation_id, + OrganisationRole::Admin as OrganisationRole + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(MemberList { + members: admin_list, + }) + } + + /// Retrieves all members of an organisation. + /// + /// # Arguments + /// * `organisation_id` - The ID of the organisation + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(MemberList)` - List of all members + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_members( + organisation_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let admin_list = sqlx::query_as!( + Member, + " + SELECT organisation_members.user_id as id, organisation_members.role AS \"role: OrganisationRole\", users.name from organisation_members + JOIN users on users.id = organisation_members.user_id + WHERE organisation_members.organisation_id = $1 + ", + organisation_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(MemberList { + members: admin_list, + }) + } + + /// Updates the list of administrators for an organisation. + /// + /// # Arguments + /// * `organisation_id` - The ID of the organisation + /// * `admin_id_list` - List of user IDs to be administrators + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the administrators were updated successfully + /// * `Err(ChaosError)` - An error if update fails + pub async fn update_admins( + organisation_id: i64, + admin_id_list: Vec, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + sqlx::query!( + "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", + organisation_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + for admin_id in admin_id_list { + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + organisation_id, + admin_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(()) + } + + /// Updates the list of members for an organisation. + /// + /// # Arguments + /// * `organisation_id` - The ID of the organisation + /// * `member_id_list` - List of user IDs to be members + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the members were updated successfully + /// * `Err(ChaosError)` - An error if update fails + pub async fn update_members( + organisation_id: i64, + member_id_list: Vec, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + sqlx::query!( + "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", + organisation_id, + OrganisationRole::User as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + for member_id in member_id_list { + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + organisation_id, + member_id, + OrganisationRole::User as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(()) + } + + pub async fn remove_admin( + organisation_id: i64, + admin_to_remove: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + sqlx::query!( + " + UPDATE organisation_members SET role = $3 WHERE user_id = $1 AND organisation_id = $2 + ", + admin_to_remove, + organisation_id, + OrganisationRole::User as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn remove_member( + organisation_id: i64, + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + sqlx::query!( + " + DELETE FROM organisation_members WHERE user_id = $1 AND organisation_id = $2 + ", + user_id, + organisation_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn update_logo( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + storage_bucket: &Bucket, + ) -> Result { + let dt = Utc::now(); + + let logo_id = Uuid::new_v4(); + let current_time = dt; + _ = sqlx::query!( + " + UPDATE organisations + SET logo = $2, updated_at = $3 + WHERE id = $1 RETURNING id + ", + id, + logo_id, + current_time + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let upload_url = + Storage::generate_put_url(format!("/logo/{id}/{logo_id}"), storage_bucket).await?; + + Ok(upload_url) + } + + pub async fn get_campaigns( + organisation_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let campaigns = sqlx::query_as!( + OrganisationCampaign, + " + SELECT id, slug, name, cover_image, description, starts_at, ends_at + FROM campaigns + WHERE organisation_id = $1 + ", + organisation_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(campaigns) + } + + pub async fn create_campaign( + organisation_id: i64, + slug: String, + name: String, + description: Option, + starts_at: DateTime, + ends_at: DateTime, + transaction: &mut Transaction<'_, Postgres>, + snowflake_id_generator: &mut SnowflakeIdGenerator, + ) -> Result { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let new_campaign_id = snowflake_id_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO campaigns (id, organisation_id, slug, name, description, starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + new_campaign_id, + organisation_id, + slug.to_lowercase(), + name, + description, + starts_at, + ends_at + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(new_campaign_id) + } + + pub async fn create_email_template( + organisation_id: i64, + name: String, + template_subject: String, + template_body: String, + transaction: &mut Transaction<'_, Postgres>, + snowflake_generator: &mut SnowflakeIdGenerator, + ) -> Result { + let id = snowflake_generator.real_time_generate(); + + let _ = sqlx::query!( + " + INSERT INTO email_templates (id, organisation_id, name, template_subject, template_body) + VALUES ($1, $2, $3, $4, $5) + ", + id, + organisation_id, + name, + template_subject, + template_body + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(id) + } +} diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs new file mode 100644 index 000000000..28c15a80a --- /dev/null +++ b/backend/server/src/models/question.rs @@ -0,0 +1,638 @@ +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Postgres, QueryBuilder, Transaction}; +use std::ops::DerefMut; +use sqlx::types::Json; + +/// The `Question` type that will be sent in API responses. +/// +/// +/// With the chosen `serde` representation and the use of `#[serde(flatten)]`, the JSON for a +/// `Question` will look like this: +/// ```json +/// { +/// "id": 7233828375289139200, +/// "title": "What is your favourite language?", +/// "required": true, +/// "question_type": "MultiChoice", +/// "data": { +/// "options": [ +/// { +/// "id": 7233828375387640938, +/// "display_order": 1, +/// "text": "Rust" +/// }, +/// { +/// "id": 7233828375387640954, +/// "display_order": 2, +/// "text": "Java" +/// }, +/// { +/// "id": 7233828375387640374, +/// "display_order": 3, +/// "text": "TypeScript" +/// } +/// ] +/// }, +/// "created_at": "2024-06-28T16:29:04.644008111Z", +/// "updated_at": "2024-06-30T12:14:12.458390190Z" +/// } +/// ``` +#[derive(Serialize)] +pub struct Question { + pub id: i64, + pub title: String, + pub description: Option, + pub common: bool, // Common question are shown at the start + pub roles: Vec, // (Possibly empty) list of roles the question is for + pub required: bool, + + #[serde(flatten)] + pub question_data: QuestionData, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Deserialize)] +pub struct NewQuestion { + pub title: String, + pub description: Option, + pub common: bool, + pub roles: Option>, + pub required: bool, + + #[serde(flatten)] + pub question_data: QuestionData, +} + +#[derive(Deserialize, sqlx::FromRow)] +pub struct QuestionRawData { + id: i64, + title: String, + description: Option, + common: bool, // Common question are shown at the start + roles: Vec, + required: bool, + + question_type: QuestionType, + multi_option_data: Option>>, + + created_at: DateTime, + updated_at: DateTime, +} + +impl Question { + pub async fn create( + campaign_id: i64, + title: String, + description: Option, + common: bool, + roles: Option>, + required: bool, + question_data: QuestionData, + snowflake_generator: &mut SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + question_data.validate()?; + + let id = snowflake_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO questions ( + id, title, description, common, + required, question_type, campaign_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + id, + title, + description, + common, + required, + QuestionType::from_question_data(&question_data) as QuestionType, + campaign_id + ) + .execute(transaction.deref_mut()) + .await?; + + question_data + .insert_into_db(id, transaction, snowflake_generator) + .await?; + + if !common { + for role in roles.expect("Should be !None if !common") { + sqlx::query!( + " + INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2) + ", + id, + role + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(id) + } + + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let question_raw_data: QuestionRawData = sqlx::query_as!( + QuestionRawData, + r#" + SELECT + q.id, + q.title, + q.description, + q.common, + COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + q.required, + q.question_type AS "question_type: QuestionType", + q.created_at, + q.updated_at, + to_jsonb( + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) + ) AS "multi_option_data: Json>" + FROM + questions q + LEFT JOIN + question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.id = $1 + GROUP BY + q.id + "#, + id + ) + // .bind(id) + .fetch_one(transaction.deref_mut()) + .await?; + + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Ok(Question { + id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + roles: question_raw_data.roles, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + }) + } + + pub async fn get_all_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data = sqlx::query_as!( + QuestionRawData, + r#" + SELECT + q.id, + q.title, + q.description, + q.common, + COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + q.required, + q.question_type AS "question_type: QuestionType", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS "multi_option_data: Json>" + FROM + questions q + LEFT JOIN + question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 + GROUP BY + q.id + "#, + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + roles: question_raw_data.roles, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn get_all_by_campaign_and_role( + campaign_id: i64, + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data = sqlx::query_as!( + QuestionRawData, + r#" + SELECT + q.id, + q.title, + q.description, + q.common, + COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + q.required, + q.question_type AS "question_type: QuestionType", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS "multi_option_data: Json>" + FROM + questions q + JOIN + question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 AND q.common = true AND qr.role_id = $2 + GROUP BY + q.id + "#, + campaign_id, + role_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + roles: question_raw_data.roles, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn get_all_common_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data = sqlx::query_as!( + QuestionRawData, + r#" + SELECT + q.id, + q.title, + q.description, + q.common, + COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + q.required, + q.question_type AS "question_type: QuestionType", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS "multi_option_data: Json>" + FROM + questions q + LEFT JOIN + question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 AND q.common = true + GROUP BY + q.id + "#, + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + roles: question_raw_data.roles, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn update( + id: i64, + title: String, + description: Option, + common: bool, + roles: Option>, + required: bool, + question_data: QuestionData, + transaction: &mut Transaction<'_, Postgres>, + snowflake_generator: &mut SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { + question_data.validate()?; + + let question_type_parent: QuestionTypeParent = sqlx::query_as!( + QuestionTypeParent, + r#" + UPDATE questions SET + title = $2, description = $3, common = $4, + required = $5, question_type = $6, updated_at = $7 + WHERE id = $1 + RETURNING question_type AS "question_type: QuestionType" + "#, + id, + title, + description, + common, + required, + QuestionType::from_question_data(&question_data) as QuestionType, + Utc::now() + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = QuestionData::from_question_type(&question_type_parent.question_type); + old_data.delete_from_db(id, transaction).await?; + + question_data + .insert_into_db(id, transaction, snowflake_generator) + .await?; + + sqlx::query!("DELETE FROM question_roles WHERE question_id = $1", id) + .execute(transaction.deref_mut()) + .await?; + if !common { + for role in roles.expect("Should be !None if !common") { + sqlx::query!( + " + INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2) + ", + id, + role + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(()) + } + + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!("DELETE FROM questions WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + +/// An enum that represents all the data types of question data that CHAOS can handle. +/// This stores all the data for each question type. +/// +/// \ +/// Some question types are stored in memory and JSON using the same struct, and only differ +/// in their implementation when inserting to the database and in their restrictions +/// (e.g. max 1 answer allowed in multi-choice vs. many in multi-select) +#[derive(Deserialize, Serialize)] +#[serde(tag = "question_type", content = "data")] +pub enum QuestionData { + ShortAnswer, + MultiChoice(MultiOptionData), + MultiSelect(MultiOptionData), + DropDown(MultiOptionData), + Ranking(MultiOptionData), +} + +/// An enum needed to track QuestionType in the database, +/// as DB enum does not contain the inner data. +#[derive(Deserialize, Serialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "question_type", rename_all = "PascalCase")] +pub enum QuestionType { + ShortAnswer, + MultiChoice, + MultiSelect, + DropDown, + Ranking, +} + +#[derive(Deserialize)] +pub struct QuestionTypeParent { + pub question_type: QuestionType, +} + +impl QuestionType { + fn from_question_data(question_data: &QuestionData) -> Self { + match question_data { + QuestionData::ShortAnswer => QuestionType::ShortAnswer, + QuestionData::MultiChoice(_) => QuestionType::MultiChoice, + QuestionData::MultiSelect(_) => QuestionType::MultiSelect, + QuestionData::DropDown(_) => QuestionType::DropDown, + QuestionData::Ranking(_) => QuestionType::Ranking, + } + } +} + +#[derive(Deserialize, Serialize, Default)] +pub struct MultiOptionData { + pub options: Vec, +} + +/// Each of these structs represent a row in the `multi_option_question_options` +/// table. For a `MultiChoice` question like "What is your favourite programming +/// language?", there would be rows for "Rust", "Java" and "TypeScript". +#[derive(Deserialize, Serialize)] +pub struct MultiOptionQuestionOption { + pub id: i64, + pub display_order: i32, + pub text: String, +} + +impl QuestionData { + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => QuestionData::ShortAnswer, + QuestionType::MultiChoice => QuestionData::MultiChoice(MultiOptionData::default()), + QuestionType::MultiSelect => QuestionData::MultiSelect(MultiOptionData::default()), + QuestionType::DropDown => QuestionData::DropDown(MultiOptionData::default()), + QuestionType::Ranking => QuestionData::Ranking(MultiOptionData::default()), + } + } + + fn from_question_raw_data( + question_type: QuestionType, + multi_option_data: Option>>, + ) -> Self { + if question_type == QuestionType::ShortAnswer { + QuestionData::ShortAnswer + } else if question_type == QuestionType::MultiChoice + || question_type == QuestionType::MultiSelect + || question_type == QuestionType::DropDown + || question_type == QuestionType::Ranking + { + let options = multi_option_data + .expect("Data should exist for MultiOptionData variants") + .0; + let data = MultiOptionData { options }; + + match question_type { + QuestionType::MultiChoice => QuestionData::MultiChoice(data), + QuestionType::MultiSelect => QuestionData::MultiSelect(data), + QuestionType::DropDown => QuestionData::DropDown(data), + QuestionType::Ranking => QuestionData::Ranking(data), + _ => QuestionData::ShortAnswer, // Should never be reached, hence return ShortAnswer + } + } else { + QuestionData::ShortAnswer // Should never be reached, hence return ShortAnswer + } + } + + pub fn validate(&self) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer => Ok(()), + Self::MultiChoice(data) + | Self::MultiSelect(data) + | Self::DropDown(data) + | Self::Ranking(data) => { + if !data.options.is_empty() { + return Ok(()); + }; + + Err(ChaosError::BadRequest) + } + } + } + + pub async fn insert_into_db( + self, + question_id: i64, + transaction: &mut Transaction<'_, Postgres>, + snowflake_generator: &mut SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer => Ok(()), + Self::MultiChoice(data) + | Self::MultiSelect(data) + | Self::DropDown(data) + | Self::Ranking(data) => { + let mut query_builder = + QueryBuilder::new("INSERT INTO multi_option_question_options (id, text, question_id, display_order)"); + + query_builder.push_values(data.options, |mut b, option| { + let id = snowflake_generator.real_time_generate(); + b.push_bind(id) + .push_bind(option.text) + .push_bind(question_id) + .push_bind(option.display_order); + }); + + let query = query_builder.build(); + query.execute(transaction.deref_mut()).await?; + + Ok(()) + } + } + } + + pub async fn delete_from_db( + self, + question_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer => Ok(()), + Self::MultiChoice(_) | Self::MultiSelect(_) | Self::DropDown(_) | Self::Ranking(_) => { + sqlx::query!( + "DELETE FROM multi_option_question_options WHERE question_id = $1", + question_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + } + } +} diff --git a/backend/server/src/models/rating.rs b/backend/server/src/models/rating.rs new file mode 100644 index 000000000..a6d634af8 --- /dev/null +++ b/backend/server/src/models/rating.rs @@ -0,0 +1,244 @@ +//! Application rating management for Chaos. +//! +//! This module provides functionality for managing ratings and comments +//! on applications, including creation, updates, and retrieval of rating information. + +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents a rating in the database. +/// +/// A rating is an evaluation of an application by a reviewer, +/// including a numerical score and optional comments. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Rating { + /// Unique identifier for the rating + pub id: i64, + /// ID of the application being rated + pub application_id: i64, + /// ID of the user who created the rating + pub rater_user_id: i64, + /// Numerical rating value + pub rating: i32, + /// Optional comments about the application + pub comment: Option, + /// When the rating was created + pub created_at: DateTime, + /// When the rating was last updated + pub updated_at: DateTime, +} + +/// Data structure for creating a new rating. +/// +/// This struct contains the fields needed to create a new rating, +/// excluding system-managed fields like IDs and timestamps. +#[derive(Deserialize, Serialize)] +pub struct NewRating { + /// Numerical rating value + pub rating: i32, + /// Optional comments about the application + pub comment: Option, +} + +/// Detailed view of a rating's information. +/// +/// This struct provides a complete view of a rating's details, +/// including the rater's name, used primarily for API responses. +#[derive(Deserialize, Serialize)] +pub struct RatingDetails { + /// Unique identifier for the rating + pub id: i64, + /// ID of the user who created the rating + pub rater_id: i64, + /// Name of the user who created the rating + pub rater_name: String, + /// Numerical rating value + pub rating: i32, + /// Optional comments about the application + pub comment: Option, + /// When the rating was last updated + pub updated_at: DateTime, +} + +/// Collection of ratings for an application. +/// +/// This struct represents all ratings associated with a specific application, +/// used for API responses. +#[derive(Deserialize, Serialize)] +pub struct ApplicationRatings { + /// List of ratings for the application + pub ratings: Vec, +} + +impl Rating { + /// Creates a new rating for an application. + /// + /// # Arguments + /// * `new_rating` - The rating data to create + /// * `application_id` - The ID of the application being rated + /// * `rater_id` - The ID of the user creating the rating + /// * `snowflake_generator` - A generator for creating unique IDs + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the rating was created successfully + /// * `Err(ChaosError)` - An error if creation fails + pub async fn create( + new_rating: NewRating, + application_id: i64, + rater_id: i64, + snowflake_generator: &mut SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let rating_id = snowflake_generator.real_time_generate(); + let rating = new_rating.rating; + let comment = new_rating.comment; + + sqlx::query!( + " + INSERT INTO application_ratings (id, application_id, rater_id, rating, comment) + VALUES ($1, $2, $3, $4, $5) + ", + rating_id, + application_id, + rater_id, + rating, + comment + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates an existing rating. + /// + /// # Arguments + /// * `rating_id` - The ID of the rating to update + /// * `updated_rating` - The new rating data + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the rating was updated successfully + /// * `Err(ChaosError)` - An error if update fails + pub async fn update( + rating_id: i64, + updated_rating: NewRating, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let rating = updated_rating.rating; + let comment = updated_rating.comment; + let current_time = Utc::now(); + + let _ = sqlx::query!( + " + UPDATE application_ratings + SET rating = $2, comment = $3, updated_at = $4 + WHERE id = $1 + RETURNING id + ", + rating_id, + rating, + comment, + current_time + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Retrieves a rating by its ID. + /// + /// # Arguments + /// * `rating_id` - The ID of the rating to retrieve + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(RatingDetails)` - The requested rating details + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_rating( + rating_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let rating = sqlx::query_as!( + RatingDetails, + " + SELECT r.id, rater_id, u.name as rater_name, r.rating, r.comment, r.updated_at + FROM application_ratings r + JOIN users u ON u.id = r.rater_id + WHERE r.id = $1 + ", + rating_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(rating) + } + + /// Retrieves all ratings for a specific application. + /// + /// # Arguments + /// * `application_id` - The ID of the application to get ratings for + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(ApplicationRatings)` - All ratings for the application + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_all_ratings_from_application_id( + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let ratings = sqlx::query_as!( + RatingDetails, + " + SELECT r.id, rater_id, u.name as rater_name, r.rating, r.comment, r.updated_at + FROM application_ratings r + JOIN users u ON u.id = r.rater_id + WHERE r.application_id = $1 + ", + application_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(ApplicationRatings { ratings }) + } + + /// Deletes a rating. + /// + /// # Arguments + /// * `rating_id` - The ID of the rating to delete + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the rating was deleted successfully + /// * `Err(ChaosError)` - An error if deletion fails + pub async fn delete( + rating_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + // Throws error if rating id doesn't exist. + let _ = sqlx::query!( + " + DELETE FROM application_ratings WHERE id = $1 + RETURNING id + ", + rating_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} diff --git a/backend/server/src/models/role.rs b/backend/server/src/models/role.rs new file mode 100644 index 000000000..59b116530 --- /dev/null +++ b/backend/server/src/models/role.rs @@ -0,0 +1,231 @@ +//! Role management for Chaos campaigns. +//! +//! This module provides functionality for managing roles within recruitment campaigns, +//! including creation, updates, and retrieval of role information. + +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents a role in a recruitment campaign. +/// +/// A role defines a position that can be applied for within a campaign, +/// including its availability and status. +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Role { + /// Unique identifier for the role + pub id: i64, + /// ID of the campaign this role belongs to + pub campaign_id: i64, + /// Optional name of the role + pub name: Option, + /// Detailed description of the role + pub description: String, + /// Minimum number of positions available + pub min_available: i32, + /// Maximum number of positions available + pub max_avaliable: i32, + /// Whether the role details have been finalized + pub finalised: bool, + /// When the role was created + pub created_at: DateTime, + /// When the role was last updated + pub updated_at: DateTime, +} + +/// Data structure for updating an existing role. +/// +/// This struct contains the fields that can be modified for a role, +/// excluding system-managed fields like IDs and timestamps. +#[derive(Deserialize, Serialize)] +pub struct RoleUpdate { + /// Name of the role + pub name: String, + /// Optional detailed description of the role + pub description: Option, + /// Minimum number of positions available + pub min_available: i32, + /// Maximum number of positions available + pub max_avaliable: i32, + /// Whether the role details have been finalized + pub finalised: bool, +} + +/// Detailed view of a role's information. +/// +/// This struct provides a complete view of a role's details, +/// used primarily for API responses. +#[derive(Deserialize, Serialize)] +pub struct RoleDetails { + /// Unique identifier for the role + pub id: i64, + /// ID of the campaign this role belongs to + pub campaign_id: i64, + /// Name of the role + pub name: String, + /// Optional detailed description of the role + pub description: Option, + /// Minimum number of positions available + pub min_available: i32, + /// Maximum number of positions available + pub max_available: i32, + /// Whether the role details have been finalized + pub finalised: bool, +} + +impl Role { + /// Creates a new role in a campaign. + /// + /// # Arguments + /// * `campaign_id` - The ID of the campaign to create the role in + /// * `role_data` - The data for the new role + /// * `transaction` - A mutable reference to the database transaction + /// * `snowflake_generator` - A generator for creating unique IDs + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the role was created successfully + /// * `Err(ChaosError)` - An error if creation fails + pub async fn create( + campaign_id: i64, + role_data: RoleUpdate, + transaction: &mut Transaction<'_, Postgres>, + snowflake_generator: &mut SnowflakeIdGenerator, + ) -> Result { + let id = snowflake_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO campaign_roles (id, campaign_id, name, description, min_available, max_available, finalised) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + id, + campaign_id, + role_data.name, + role_data.description, + role_data.min_available, + role_data.max_avaliable, + role_data.finalised + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(id) + } + + /// Retrieves a role by its ID. + /// + /// # Arguments + /// * `id` - The ID of the role to retrieve + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(RoleDetails)` - The requested role details + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result { + let role = sqlx::query_as!( + RoleDetails, + " + SELECT id, campaign_id, name, description, min_available, max_available, finalised + FROM campaign_roles + WHERE id = $1 + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(role) + } + + /// Deletes a role. + /// + /// # Arguments + /// * `id` - The ID of the role to delete + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the role was deleted successfully + /// * `Err(ChaosError)` - An error if deletion fails + pub async fn delete(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + DELETE FROM campaign_roles WHERE id = $1 RETURNING id + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates an existing role. + /// + /// # Arguments + /// * `id` - The ID of the role to update + /// * `role_data` - The new data for the role + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the role was updated successfully + /// * `Err(ChaosError)` - An error if update fails + pub async fn update( + id: i64, + role_data: RoleUpdate, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE campaign_roles + SET (name, description, min_available, max_available, finalised) = ($2, $3, $4, $5, $6) + WHERE id = $1 RETURNING id + ", + id, + role_data.name, + role_data.description, + role_data.min_available, + role_data.max_avaliable, + role_data.finalised + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Retrieves all roles in a specific campaign. + /// + /// # Arguments + /// * `campaign_id` - The ID of the campaign to get roles from + /// * `transaction` - A mutable reference to the database transaction + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(Vec)` - List of roles in the campaign + /// * `Err(ChaosError)` - An error if retrieval fails + pub async fn get_all_in_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let roles = sqlx::query_as!( + RoleDetails, + " + SELECT id, campaign_id, name, description, min_available, max_available, finalised + FROM campaign_roles + WHERE campaign_id = $1 + ", + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(roles) + } +} diff --git a/backend/server/src/models/storage.rs b/backend/server/src/models/storage.rs new file mode 100644 index 000000000..09afc4a46 --- /dev/null +++ b/backend/server/src/models/storage.rs @@ -0,0 +1,88 @@ +//! S3-compatible storage integration for Chaos. +//! +//! This module provides functionality for interacting with S3-compatible storage services. +//! It handles bucket initialization and URL generation for file uploads. + +use crate::models::error::ChaosError; +use s3::creds::Credentials; +use s3::{Bucket, Region}; +use std::env; + +/// Storage service for handling S3-compatible storage operations. +/// +/// This struct provides methods for initializing S3 bucket connections and +/// generating pre-signed URLs for file uploads. +pub struct Storage; + +impl Storage { + /// Initializes a new S3 bucket connection using environment variables. + /// + /// # Environment Variables + /// * `S3_BUCKET_NAME` - The name of the S3 bucket + /// * `S3_ACCESS_KEY` - The access key for S3 authentication + /// * `S3_SECRET_KEY` - The secret key for S3 authentication + /// * `S3_ENDPOINT` - The endpoint URL for the S3 service + /// * `S3_REGION_NAME` - The region name for the S3 service + /// + /// # Returns + /// Returns a configured `Bucket` instance for S3 operations. + /// + /// # Panics + /// Panics if any of the required environment variables are not set. + pub fn init_bucket() -> Bucket { + let bucket_name = env::var("S3_BUCKET_NAME") + .expect("Error getting S3 BUCKET NAME") + .to_string(); + let access_key = env::var("S3_ACCESS_KEY") + .expect("Error getting S3 CREDENTIALS") + .to_string(); + let secret_key = env::var("S3_SECRET_KEY") + .expect("Error getting S3 CREDENTIALS") + .to_string(); + let endpoint = env::var("S3_ENDPOINT") + .expect("Error getting S3 ENDPOINT") + .to_string(); + let region_name = env::var("S3_REGION_NAME") + .expect("Error getting S3 REGION NAME") + .to_string(); + + let credentials = Credentials::new( + Option::from(access_key.as_str()), + Option::from(secret_key.as_str()), + None, + None, + None, + ) + .unwrap(); + + let region = Region::Custom { + region: region_name, + endpoint, + }; + + let bucket = Bucket::new(&bucket_name, region, credentials).unwrap(); + // TODO: Change depending on style used by provider + // bucket.set_path_style(); + + bucket + } + + /// Generates a pre-signed URL for uploading a file to S3. + /// + /// # Arguments + /// * `path` - The path where the file will be stored in the bucket + /// * `bucket` - A reference to the initialized S3 bucket + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(String)` - The pre-signed URL for uploading + /// * `Err(ChaosError)` - An error if URL generation fails + /// + /// # Note + /// The generated URL is valid for 1 hour (3600 seconds). + pub async fn generate_put_url(path: String, bucket: &Bucket) -> Result { + let url = bucket.presign_put(path, 3600, None).await?; + + Ok(url) + } +} diff --git a/backend/server/src/models/transaction.rs b/backend/server/src/models/transaction.rs new file mode 100644 index 000000000..d11d49d76 --- /dev/null +++ b/backend/server/src/models/transaction.rs @@ -0,0 +1,53 @@ +//! Database transaction handling for Chaos. +//! +//! This module provides functionality for managing database transactions +//! in a type-safe way, with integration into the Axum web framework. + +use crate::models::app::AppState; +use crate::models::error::ChaosError; +use axum::async_trait; +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; +use sqlx::{Postgres, Transaction}; + +/// A wrapper around a PostgreSQL transaction. +/// +/// This struct provides a type-safe way to handle database transactions +/// in request handlers. It automatically begins a transaction when extracted +/// from a request. +pub struct DBTransaction<'a> { + /// The underlying PostgreSQL transaction + pub tx: Transaction<'a, Postgres>, +} + +/// Implementation of `FromRequestParts` for `DBTransaction`. +/// +/// This allows `DBTransaction` to be used as an extractor in Axum route handlers. +/// When extracted, it automatically begins a new transaction from the application's +/// database connection pool. +#[async_trait] +impl FromRequestParts for DBTransaction<'_> +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + /// Extracts a new database transaction from the request state. + /// + /// # Arguments + /// * `_` - The request parts (unused) + /// * `state` - The application state containing the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(DBTransaction)` - A new database transaction + /// * `Err(ChaosError)` - An error if transaction creation fails + async fn from_request_parts(_: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + Ok(DBTransaction { + tx: app_state.db.begin().await?, + }) + } +} diff --git a/backend/server/src/models/user.rs b/backend/server/src/models/user.rs new file mode 100644 index 000000000..f38ad1974 --- /dev/null +++ b/backend/server/src/models/user.rs @@ -0,0 +1,307 @@ +//! User management module for the Chaos application. +//! +//! This module provides functionality for managing users, including retrieval +//! and updating of user information such as name, pronouns, gender, zID, and degree details. + +use crate::models::error::ChaosError; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents the role of a user in the system. +/// +/// Users can have different roles that determine their permissions and access levels. +#[derive(Deserialize, Serialize, sqlx::Type, Clone)] +#[sqlx(type_name = "user_role", rename_all = "PascalCase")] +pub enum UserRole { + /// Regular user with basic access + User, + /// Super user with administrative privileges + SuperUser, +} + +/// Detailed information about a user. +/// +/// This struct contains all the personal and academic information about a user +/// that can be displayed in the application. +#[derive(Deserialize, Serialize, FromRow)] +pub struct UserDetails { + /// Unique identifier for the user + pub id: i64, + /// User's email address + pub email: String, + /// User's zID (UNSW student ID) + pub zid: Option, + /// User's full name + pub name: String, + /// User's preferred pronouns + pub pronouns: Option, + /// User's gender + pub gender: Option, + /// User's degree name + pub degree_name: Option, + /// Year the user started their degree + pub degree_starting_year: Option, +} + +/// Complete user information including role. +/// +/// This struct extends UserDetails to include the user's role in the system. +#[derive(Deserialize, Serialize, FromRow)] +pub struct User { + /// Unique identifier for the user + pub id: i64, + /// User's email address + pub email: String, + /// User's zID (UNSW student ID) + pub zid: Option, + /// User's full name + pub name: String, + /// User's preferred pronouns + pub pronouns: Option, + /// User's gender + pub gender: Option, + /// User's degree name + pub degree_name: Option, + /// Year the user started their degree + pub degree_starting_year: Option, + /// User's role in the system + pub role: UserRole, +} + +/// Data structure for updating a user's name. +#[derive(Deserialize, Serialize)] +pub struct UserName { + /// New name for the user + pub name: String, +} + +/// Data structure for updating a user's pronouns. +#[derive(Deserialize, Serialize)] +pub struct UserPronouns { + /// New pronouns for the user + pub pronouns: String, +} + +/// Data structure for updating a user's gender. +#[derive(Deserialize, Serialize)] +pub struct UserGender { + /// New gender for the user + pub gender: String, +} + +/// Data structure for updating a user's zID. +#[derive(Deserialize, Serialize)] +pub struct UserZid { + /// New zID for the user + pub zid: String, +} + +/// Data structure for updating a user's degree information. +#[derive(Deserialize, Serialize)] +pub struct UserDegree { + /// New degree name for the user + pub degree_name: String, + /// New degree starting year for the user + pub degree_starting_year: i32, +} + +impl User { + /// Retrieves a user by their ID. + /// + /// # Arguments + /// + /// * `id` - ID of the user to retrieve + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result` - User details or error + pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result { + let user = sqlx::query_as!( + User, + r#" + SELECT id, email, zid, name, pronouns, gender, degree_name, + degree_starting_year, role AS "role!: UserRole" + FROM users WHERE id = $1 + "#, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(user) + } + + /// Updates a user's name. + /// + /// # Arguments + /// + /// * `id` - ID of the user to update + /// * `name` - New name for the user + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_name( + id: i64, + name: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE users SET name = $1 WHERE id = $2 RETURNING id + ", + name, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates a user's pronouns. + /// + /// # Arguments + /// + /// * `id` - ID of the user to update + /// * `pronouns` - New pronouns for the user + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_pronouns( + id: i64, + pronouns: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE users SET pronouns = $1 WHERE id = $2 RETURNING id + ", + pronouns, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates a user's gender. + /// + /// # Arguments + /// + /// * `id` - ID of the user to update + /// * `gender` - New gender for the user + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_gender( + id: i64, + gender: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE users SET gender = $1 WHERE id = $2 RETURNING id + ", + gender, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates a user's zID. + /// + /// # Arguments + /// + /// * `id` - ID of the user to update + /// * `zid` - New zID for the user + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_zid(id: i64, zid: String, transaction: &mut Transaction<'_, Postgres>,) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE users SET zid = $1 WHERE id = $2 RETURNING id + ", + zid, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Updates a user's degree information. + /// + /// # Arguments + /// + /// * `id` - ID of the user to update + /// * `degree_name` - New degree name for the user + /// * `degree_starting_year` - New degree starting year for the user + /// * `pool` - Database connection pool + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Success or error + pub async fn update_degree( + id: i64, + degree_name: String, + degree_starting_year: i32, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE users SET degree_name = $1, degree_starting_year = $2 WHERE id = $3 RETURNING id + ", + degree_name, + degree_starting_year, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + + /// Creates a User, This should only used for database seeding + pub async fn create_user( + data: User, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!( + " + INSERT INTO users (id, email, zid, name, pronouns, gender, degree_name, degree_starting_year, role) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::user_role) + ", + data.id, + data.email, + data.zid, + data.name, + data.pronouns, + data.gender, + data.degree_name, + data.degree_starting_year, + data.role as UserRole + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + +} diff --git a/backend/server/src/organisation.rs b/backend/server/src/organisation.rs deleted file mode 100644 index 7088947df..000000000 --- a/backend/server/src/organisation.rs +++ /dev/null @@ -1,375 +0,0 @@ -use crate::error::JsonErr; -use crate::images::get_image_path; -use crate::{ - database::{ - models::{ - Campaign, NewOrganisation, NewOrganisationUser, Organisation, OrganisationUser, - SuperUser, User, - }, - schema::AdminLevel, - Database, - }, - images::{get_http_image_path, save_image, try_decode_data, ImageLocation}, -}; -use chrono::NaiveDateTime; -use rocket::{ - data::Data, - delete, get, - http::Status, - post, put, - serde::{json::Json, Deserialize, Serialize}, -}; -use std::collections::HashMap; -use std::fs::remove_file; -use uuid::Uuid; - -#[derive(Serialize)] -pub enum NewOrgError { - OrgNameAlreadyExists, - FailedToJoin, -} - -#[derive(Serialize)] -pub enum OrgError { - OrgNotFound, - InsufficientPerms, - UserIsNotInOrg, - UserNotFound, - UserAlreadyInOrg, - Unknown, -} - -#[post("/", data = "")] -pub async fn new( - organisation: Json, - user: SuperUser, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let org = NewOrganisation::insert(&organisation, &conn) - .ok_or(JsonErr(NewOrgError::OrgNameAlreadyExists, Status::NotFound))?; - - let org_user = NewOrganisationUser { - user_id: user.user().id, - organisation_id: org.id, - admin_level: AdminLevel::Admin, - }; - - org_user - .insert(conn) - .ok_or(JsonErr(NewOrgError::FailedToJoin, Status::Forbidden))?; - - Ok(Json(org)) - }) - .await -} - -// ============ /organisation/ ============ - -#[get("/")] -pub async fn get_from_id( - org_id: i32, - _user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - Organisation::get_from_id(&conn, org_id) - .ok_or(JsonErr(OrgError::OrgNotFound, Status::NotFound)) - .map(|mut v| { - v.logo = v - .logo - .map(|logo_uuid| get_http_image_path(ImageLocation::ORGANISATIONS, &logo_uuid)); - Json(v) - }) - }) - .await -} - -#[get("/", data = "")] -pub async fn get_from_ids( - orgs: Json>, - _user: User, - db: Database, -) -> Result>, JsonErr> { - db.run(move |conn| { - let mut res = HashMap::with_capacity(orgs.len()); - - for id in orgs.into_inner() { - res.insert( - id, - Organisation::get_from_id(&conn, id) - .ok_or(JsonErr(OrgError::OrgNotFound, Status::NotFound))?, - ); - } - - Ok(Json(res)) - }) - .await -} - -#[delete("/")] -pub async fn delete(org_id: i32, _user: SuperUser, db: Database) -> Result<(), JsonErr> { - db.run(move |conn| { - Organisation::delete_deep(&conn, org_id) - .ok_or(JsonErr(OrgError::OrgNotFound, Status::NotFound)) - }) - .await -} - -// ============ /organisation//superusers ============ - -#[get("//superusers")] -pub async fn get_admins( - org_id: i32, - _user: User, - db: Database, -) -> Result>, JsonErr> { - let res = db - .run(move |conn| Organisation::get_admin_ids(&conn, org_id)) - .await; - - match res { - Some(ids) => Ok(Json(ids)), - None => Err(JsonErr(OrgError::OrgNotFound, Status::NotFound)), - } -} - -#[derive(Serialize)] -pub enum LogoError { - Unauthorized, - ImageDeletionFailure, - ImageStoreFailure, -} - -#[put("//logo", data = "")] -pub async fn set_logo( - org_id: i32, - user: User, - db: Database, - image: Data<'_>, -) -> Result, JsonErr> { - db.run(move |conn| { - OrganisationUser::organisation_admin_level(org_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(JsonErr(LogoError::Unauthorized, Status::Forbidden))) - }) - .await?; - - let old_logo_uuid = db - .run(move |conn| Organisation::get_logo(&conn, org_id)) - .await; - let logo_uuid = Uuid::new_v4().as_hyphenated().to_string() + ".webp"; - - let image = try_decode_data(image).await.or_else(|_| { - Err(JsonErr( - LogoError::ImageDeletionFailure, - Status::InternalServerError, - )) - })?; - - save_image(image, ImageLocation::ORGANISATIONS, &logo_uuid) - .map_err(|_| JsonErr(LogoError::ImageStoreFailure, Status::InternalServerError))?; - - let logo_uuid_clone = logo_uuid.clone(); - - db.run(move |conn| Organisation::set_logo(&conn, org_id, &logo_uuid_clone)) - .await; - - if let Some(uuid) = old_logo_uuid { - remove_file(get_image_path(ImageLocation::ORGANISATIONS, &uuid)).ok(); - } - - Ok(Json(get_http_image_path( - ImageLocation::ORGANISATIONS, - &logo_uuid, - ))) -} - -#[put("//admins", data = "")] -pub async fn set_admins( - org_id: i32, - user: User, - db: Database, - admins: Json>, -) -> Result, JsonErr> { - let res = db - .run(move |conn| Organisation::get_admin_ids(&conn, org_id)) - .await; - - match res { - Some(ids) => { - if !ids.contains(&user.id) { - return Err(JsonErr(OrgError::InsufficientPerms, Status::Forbidden)); - } else { - db.run(move |conn| Organisation::set_admins(&conn, org_id, &admins)) - .await; - Ok(Json(())) - } - } - - None => Err(JsonErr(OrgError::OrgNotFound, Status::NotFound)), - } -} - -#[get("//is_admin")] -pub async fn is_admin(org_id: i32, user: User, db: Database) -> Json { - let res = db - .run(move |conn| Organisation::get_admin_ids(&conn, org_id)) - .await; - - match res { - Some(ids) => Json(ids.contains(&user.id) || user.superuser), - None => Json(false), - } -} - -#[derive(Serialize)] -pub struct CampaignResponse { - pub id: i32, - pub name: String, - pub cover_image: Option, - pub description: String, - pub starts_at: NaiveDateTime, - pub ends_at: NaiveDateTime, - pub published: bool, -} - -impl std::convert::From for CampaignResponse { - fn from(campaign: Campaign) -> Self { - Self { - id: campaign.id, - name: campaign.name, - cover_image: campaign - .cover_image - .map(|image| get_http_image_path(ImageLocation::CAMPAIGNS, &image)), - description: campaign.description, - starts_at: campaign.starts_at, - ends_at: campaign.ends_at, - published: campaign.published, - } - } -} - -#[derive(Serialize)] -pub struct GetCampaignsResponse { - campaigns: Vec, -} - -#[get("//campaigns")] -pub async fn get_associated_campaigns( - org_id: i32, - user: User, - db: Database, -) -> Json { - db.run(move |conn| { - let is_director = OrganisationUser::organisation_admin_level(org_id, user.id, conn) - .is_at_least_director() - .check() - .is_ok(); - - Json(GetCampaignsResponse { - campaigns: Campaign::get_all_from_org_id(conn, org_id) - .into_iter() - .filter(|v| v.published || is_director) - .map(CampaignResponse::from) - .collect(), - }) - }) - .await -} - -#[derive(Serialize, Deserialize)] -pub struct EmailInvite { - pub email: String, - pub admin_level: AdminLevel, -} - -#[post("//invite", data = "")] -pub async fn invite_email( - organisation_id: i32, - input: Json, - user: User, - db: Database, -) -> Result<(), JsonErr> { - let EmailInvite { email, admin_level } = input.into_inner(); - db.run(move |conn| { - let mut level = OrganisationUser::organisation_admin_level(organisation_id, user.id, conn) - .check() - .map_err(|_| JsonErr(OrgError::InsufficientPerms, Status::Forbidden))? - .0; - - if user.superuser { - level = AdminLevel::Admin; - } - - let invitee = User::get_from_email(conn, &email) - .ok_or(JsonErr(OrgError::UserNotFound, Status::NotFound))?; - - if level.geq(admin_level) { - let new_user = NewOrganisationUser { - user_id: invitee.id, - organisation_id, - admin_level, - }; - - new_user - .insert(conn) - .map(|_| ()) - .ok_or(JsonErr(OrgError::UserAlreadyInOrg, Status::NotAcceptable)) - } else { - Err(JsonErr(OrgError::InsufficientPerms, Status::Forbidden)) - } - }) - .await -} - -#[post("//invite/", data = "")] -pub async fn invite_uid( - organisation_id: i32, - admin_level: Json, - user_id: i32, - user: User, - db: Database, -) -> Result<(), JsonErr> { - db.run(move |conn| { - let mut level = OrganisationUser::organisation_admin_level(organisation_id, user.id, conn) - .check() - .map_err(|_| JsonErr(OrgError::InsufficientPerms, Status::Forbidden))? - .0; - - if user.superuser { - level = AdminLevel::Admin; - } - - let admin_level = admin_level.into_inner(); - - if level.geq(admin_level) { - match OrganisationUser::get(conn, organisation_id, user_id) { - Some(u) => { - if u.admin_level.geq(u.admin_level) { - Err(JsonErr(OrgError::InsufficientPerms, Status::Forbidden)) - } else { - u.update_admin_level(conn, admin_level) - .ok_or(JsonErr(OrgError::Unknown, Status::InternalServerError)) - } - } - None => { - let new_user = NewOrganisationUser { - user_id, - organisation_id, - admin_level, - }; - - new_user - .insert(conn) - .map(|_| ()) - .ok_or(JsonErr(OrgError::UserAlreadyInOrg, Status::NotAcceptable)) - } - } - } else { - Err(JsonErr(OrgError::InsufficientPerms, Status::Forbidden)) - } - }) - .await -} diff --git a/backend/server/src/permissions.rs b/backend/server/src/permissions.rs deleted file mode 100644 index 2b23abdf2..000000000 --- a/backend/server/src/permissions.rs +++ /dev/null @@ -1,254 +0,0 @@ -use crate::database::models::OrganisationUser; -use crate::database::schema::*; -use diesel::{ - expression_methods::ExpressionMethods, query_dsl::QueryDsl, JoinOnDsl, PgConnection, - RunQueryDsl, -}; - -/* -Permission Documentation - -The implmentation below is designed to be used in this pattern: - - db.run(move |conn| { - let campaign = Campaign::get_from_id(conn, campaign_id) - .ok_or_else(|| Json(RolesError::CampaignNotFound))?; - - OrganisationUser::campaign_admin_level(campaign_id, user.id, &conn) - .is_at_least_director() - .and(campaign.draft) // is at least director AND campaign is a draft - .check() - .or_else(|_| Err(Jsorganisation_users::on(RolesError::Unauthorized)))?; - }).await - -This allows you to search for different admin levels while keeping everything in one place. - - -Note on repeated code in this file: - -I originally wanted to do a dynamic set of inner joins, then execute in a pattern like this: -(this would have been implemented with BoxedExpression) - - db.run(move |conn| { - let campaign = Campaign::get_from_id(conn, campaign_id) - .ok_or_else(|| Json(RolesError::CampaignNotFound))?; - - OrganisationUser::campaign_admin_level(campaign_id, user.id) - .is_at_least_director() - .and(campaign.draft) // is at least director AND campaign is a draft - .check(&conn) - .or_else(|_| Err(Json(RolesError::Unauthorized)))?; - }).await - -However, diesel doesn't yet support boxed queries with inner joins (only single tables) -At the point at which this is supported, this file can be cleaned up massively and the above -syntax can be implemented (oh woe the youth of the rust language) -*/ - -pub enum PermissionError { - Unauthorized, - ConditionNotMet, -} - -pub struct AdminLevelUser { - // (admin_level, is_superuser) - res: Result<(AdminLevel, bool), PermissionError>, -} - -impl AdminLevelUser { - pub fn is_at_least_director(self) -> AdminLevelUser { - match self.res { - Ok((_, true)) | Ok((AdminLevel::Admin, false)) | Ok((AdminLevel::Director, false)) => { - self - } - _ => AdminLevelUser { - res: Err(PermissionError::Unauthorized), - }, - } - } - - pub fn is_admin(self) -> AdminLevelUser { - match self.res { - Ok((_, true)) | Ok((AdminLevel::Admin, false)) => self, - _ => AdminLevelUser { - res: Err(PermissionError::Unauthorized), - }, - } - } - - pub fn is_superuser(self) -> AdminLevelUser { - match self.res { - Ok((_, true)) => self, - _ => AdminLevelUser { - res: Err(PermissionError::Unauthorized), - }, - } - } - - pub fn and(self, condition: bool) -> AdminLevelUser { - match condition { - true => self, - false => AdminLevelUser { - res: Err(PermissionError::ConditionNotMet), - }, - } - } - - pub fn or(self, condition: bool) -> AdminLevelUser { - match condition { - true => self, - false => match self.res { - Ok((_, _)) => self, - _ => AdminLevelUser { - res: Err(PermissionError::ConditionNotMet), - }, - }, - } - } - - pub fn check(self) -> Result<(AdminLevel, bool), PermissionError> { - self.res - } -} - -impl std::convert::From> for AdminLevelUser { - fn from(res: Result<(AdminLevel, bool), PermissionError>) -> Self { - AdminLevelUser { res } - } -} - -fn is_superuser(user_id: i32, conn: &PgConnection) -> bool { - match users::table - .filter(users::id.eq(user_id)) - .select(users::superuser) - .first(conn) - .ok() - { - Some(true) => true, - _ => false, - } -} - -impl OrganisationUser { - pub fn organisation_admin_level( - org_id: i32, - user_id: i32, - conn: &PgConnection, - ) -> AdminLevelUser { - if is_superuser(user_id, conn) { - return AdminLevelUser { - res: Ok((AdminLevel::Admin, true)), - }; - } - organisation_users::table - .filter(organisation_users::organisation_id.eq(org_id)) - .inner_join(users::table.on(users::id.eq(organisation_users::user_id))) - .filter(users::id.eq(user_id)) - .select((organisation_users::admin_level, users::superuser)) - .first(conn) - .or_else(|_| Err(PermissionError::Unauthorized)) - .into() - } - - pub fn campaign_admin_level( - campaign_id: i32, - user_id: i32, - conn: &PgConnection, - ) -> AdminLevelUser { - if is_superuser(user_id, conn) { - return AdminLevelUser { - res: Ok((AdminLevel::Admin, true)), - }; - } - campaigns::table - .filter(campaigns::id.eq(campaign_id)) - .inner_join(organisations::table.on(organisations::id.eq(campaigns::organisation_id))) - .inner_join( - organisation_users::table - .on(organisation_users::organisation_id.eq(organisations::id)), - ) - .inner_join(users::table.on(users::id.eq(organisation_users::user_id))) - .filter(users::id.eq(user_id)) - .select((organisation_users::admin_level, users::superuser)) - .first(conn) - .or_else(|_| Err(PermissionError::Unauthorized)) - .into() - } - - pub fn application_admin_level( - application_id: i32, - user_id: i32, - conn: &PgConnection, - ) -> AdminLevelUser { - if is_superuser(user_id, conn) { - return AdminLevelUser { - res: Ok((AdminLevel::Admin, true)), - }; - } - applications::table - .filter(applications::id.eq(application_id)) - .inner_join(roles::table.on(roles::id.eq(applications::role_id))) - .inner_join(campaigns::table.on(campaigns::id.eq(roles::campaign_id))) - .inner_join(organisations::table.on(organisations::id.eq(campaigns::organisation_id))) - .inner_join( - organisation_users::table - .on(organisation_users::organisation_id.eq(organisations::id)), - ) - .inner_join(users::table.on(users::id.eq(organisation_users::user_id))) - .filter(users::id.eq(user_id)) - .select((organisation_users::admin_level, users::superuser)) - .first(conn) - .or_else(|_| Err(PermissionError::Unauthorized)) - .into() - } - - pub fn role_admin_level(role_id: i32, user_id: i32, conn: &PgConnection) -> AdminLevelUser { - if is_superuser(user_id, conn) { - return AdminLevelUser { - res: Ok((AdminLevel::Admin, true)), - }; - } - roles::table - .filter(roles::id.eq(role_id)) - .inner_join(campaigns::table.on(campaigns::id.eq(roles::campaign_id))) - .inner_join(organisations::table.on(organisations::id.eq(campaigns::organisation_id))) - .inner_join( - organisation_users::table - .on(organisation_users::organisation_id.eq(organisations::id)), - ) - .inner_join(users::table.on(users::id.eq(organisation_users::user_id))) - .filter(users::id.eq(user_id)) - .select((organisation_users::admin_level, users::superuser)) - .first(conn) - .or_else(|_| Err(PermissionError::Unauthorized)) - .into() - } - - pub fn comment_admin_level( - comment_id: i32, - user_id: i32, - conn: &PgConnection, - ) -> AdminLevelUser { - if is_superuser(user_id, conn) { - return AdminLevelUser { - res: Ok((AdminLevel::Admin, true)), - }; - } - comments::table - .filter(comments::id.eq(comment_id)) - .inner_join(applications::table.on(applications::id.eq(comments::application_id))) - .inner_join(roles::table.on(roles::id.eq(applications::role_id))) - .inner_join(campaigns::table.on(campaigns::id.eq(roles::campaign_id))) - .inner_join(organisations::table.on(organisations::id.eq(campaigns::organisation_id))) - .inner_join( - organisation_users::table - .on(organisation_users::organisation_id.eq(organisations::id)), - ) - .inner_join(users::table.on(users::id.eq(organisation_users::user_id))) - .filter(users::id.eq(user_id)) - .select((organisation_users::admin_level, users::superuser)) - .first(conn) - .or_else(|_| Err(PermissionError::Unauthorized)) - .into() - } -} diff --git a/backend/server/src/question.rs b/backend/server/src/question.rs deleted file mode 100644 index d45bc56f3..000000000 --- a/backend/server/src/question.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::database::{ - models::{ - Campaign, OrganisationUser, Question, QuestionResponse, Role, UpdateQuestionInput, User, - }, - Database, -}; -use crate::error::JsonErr; - -use rocket::{ - delete, get, - http::Status, - put, - serde::{json::Json, Serialize}, -}; - -use std::convert::From; - -#[derive(Serialize)] -pub enum QuestionError { - QuestionNotFound, - UpdateFailed, - InsufficientPermissions, -} - -// TODO: may be useless function, also awfully inefficient. -#[get("/")] -pub async fn get_question( - user: User, - db: Database, - question_id: i32, -) -> Result, JsonErr> { - db.run(move |conn| { - let q = Question::get_from_id(&conn, question_id) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - let r = Role::get_from_id(&conn, q.get_first_role()) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - let c = Campaign::get_from_id(&conn, r.campaign_id) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - OrganisationUser::role_admin_level(q.get_first_role(), user.id, conn) - .is_at_least_director() - .or(c.published) - .check() - .map_err(|_| JsonErr(QuestionError::InsufficientPermissions, Status::Forbidden))?; - Ok(q) - }) - .await - .map(|q| Json(QuestionResponse::from(q))) -} - -#[put("/", data = "")] -pub async fn edit_question( - db: Database, - question_id: i32, - update_question: Json, - user: User, -) -> Result<(), JsonErr> { - db.run(move |conn| { - let question = Question::get_from_id(&conn, question_id) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - let role = Role::get_from_id(conn, question.get_first_role()) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - - OrganisationUser::role_admin_level(role.id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| { - Err(JsonErr( - QuestionError::InsufficientPermissions, - Status::Forbidden, - )) - })?; - - Question::update(&conn, question_id, update_question.into_inner()).ok_or(JsonErr( - QuestionError::UpdateFailed, - Status::InternalServerError, - )) - }) - .await -} - -#[delete("/")] -pub async fn delete_question( - db: Database, - question_id: i32, - user: User, -) -> Result<(), JsonErr> { - db.run(move |conn| { - let question = Question::get_from_id(&conn, question_id) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - let role = Role::get_from_id(conn, question.get_first_role()) - .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; - - OrganisationUser::role_admin_level(role.id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| { - Err(JsonErr( - QuestionError::InsufficientPermissions, - Status::Forbidden, - )) - })?; - - if Question::delete(&conn, question_id) { - Ok(()) - } else { - Err(JsonErr( - QuestionError::UpdateFailed, - Status::InternalServerError, - )) - } - }) - .await -} diff --git a/backend/server/src/role.rs b/backend/server/src/role.rs deleted file mode 100644 index 51426c84b..000000000 --- a/backend/server/src/role.rs +++ /dev/null @@ -1,228 +0,0 @@ -use crate::database::{ - models::{ - Application, Campaign, GetQuestionsResponse, OrganisationUser, Question, Role, RoleUpdate, - User, - }, - schema::ApplicationStatus, - Database, -}; -use chrono::NaiveDateTime; -use diesel::PgConnection; -use rocket::{ - delete, get, post, put, - serde::{json::Json, Serialize}, -}; - -#[derive(Serialize)] -pub enum RoleError { - RoleUpdateFailure, - RoleNotFound, - CampaignNotFound, - Unauthorized, - RoleAlreadyExists, -} - -#[derive(Serialize)] -pub struct RoleResponse { - name: String, - description: Option, - min_available: i32, - max_available: i32, -} - -impl From for RoleResponse { - fn from(role: Role) -> Self { - RoleResponse { - name: role.name, - description: role.description, - min_available: role.min_available, - max_available: role.max_available, - } - } -} - -#[get("/")] -pub async fn get_role( - role_id: i32, - _user: User, - db: Database, -) -> Result, Json> { - let res = db.run(move |conn| Role::get_from_id(&conn, role_id)).await; - - match res { - Some(role) => Ok(Json(RoleResponse::from(role))), - None => Err(Json(RoleError::RoleNotFound)), - } -} - -#[put("/", data = "")] -pub async fn update_role( - role_id: i32, - role_update: Json, - user: User, - db: Database, -) -> Result, Json> { - db.run(move |conn| { - OrganisationUser::role_admin_level(role_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(Json(RoleError::Unauthorized)))?; - - let role = Role::update(conn, role_id, &role_update) - .ok_or_else(|| Json(RoleError::RoleUpdateFailure))?; - - Ok(Json(role.into())) - }) - .await -} - -#[delete("/")] -pub async fn delete_role(role_id: i32, user: User, db: Database) -> Result<(), Json> { - db.run(move |conn| { - OrganisationUser::role_admin_level(role_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(Json(RoleError::Unauthorized)))?; - - Role::delete_deep(conn, role_id).ok_or_else(|| Json(RoleError::RoleUpdateFailure))?; - - Ok(()) - }) - .await -} - -#[post("/", data = "")] -pub async fn new_role( - role: Json, - user: User, - db: Database, -) -> Result<(), Json> { - db.run(move |conn| { - OrganisationUser::campaign_admin_level(role.campaign_id, user.id, &conn) - .is_at_least_director() - .check() - .or_else(|_| Err(Json(RoleError::Unauthorized)))?; - - RoleUpdate::insert(&role, &conn).ok_or_else(|| Json(RoleError::RoleAlreadyExists))?; - - Ok(()) - }) - .await -} - -#[derive(Serialize)] -pub enum QuestionsError { - RoleNotFound, - CampaignNotFound, - Unauthorized, - UserNotFound, -} - -#[get("//questions")] -pub async fn get_questions( - role_id: i32, - user: User, - db: Database, -) -> Result, Json> { - // First check that the role is valid and the user should be able to access the ids. - // We can't use the helper function below since behaviour depends on the draft - // status of the campaign. - - db.run(move |conn| { - let role = Role::get_from_id(conn, role_id).ok_or(Json(QuestionsError::RoleNotFound))?; - let campaign = Campaign::get_from_id(conn, role.campaign_id) - .ok_or(Json(QuestionsError::CampaignNotFound))?; - - // Prevent people from viewing while it's in draft mode, - // unless they have adequate permissions - OrganisationUser::campaign_admin_level(campaign.id, user.id, &conn) - .is_at_least_director() - .or(campaign.published) - .check() - .map_err(|_| Json(QuestionsError::Unauthorized))?; - Ok(Json(GetQuestionsResponse { - questions: Question::get_all_from_role_id(conn, role_id) - .into_iter() - .map(|x| x.into()) - .collect(), - })) - }) - .await -} - -#[derive(Serialize)] -pub struct ApplicationResponse { - pub id: i32, - pub user_id: i32, - pub user_email: String, - pub user_zid: String, - pub user_display_name: String, - pub user_degree_name: String, - pub user_degree_starting_year: i32, - pub role_id: i32, - pub status: ApplicationStatus, - pub private_status: ApplicationStatus, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -impl ApplicationResponse { - pub fn from_application(app: Application, conn: &PgConnection) -> Option { - let user = User::get_from_id(conn, app.user_id)?; - - Some(ApplicationResponse { - id: app.id, - user_id: app.user_id, - user_display_name: user.display_name, - user_email: user.email, - user_degree_name: user.degree_name, - user_degree_starting_year: user.degree_starting_year, - user_zid: user.zid, - role_id: app.role_id, - status: app.status, - private_status: app.private_status, - created_at: app.created_at, - updated_at: app.updated_at, - }) - } -} - -#[derive(Serialize)] -pub struct GetApplicationsResponse { - pub applications: Vec, -} - -#[get("//applications")] -pub async fn get_applications( - role_id: i32, - user: User, - db: Database, -) -> Result, Json> { - // First check that the role is valid and the user should be able to access the ids. - // We can't use the helper function below since behaviour depends on the draft - // status of the campaign. - - db.run(move |conn| { - // NOTE: admin_level doesn't give good error info when eg. role is not found - // (just says unauthorized) - OrganisationUser::role_admin_level(role_id, user.id, conn) - .is_at_least_director() - .check() - .map_err(|_| QuestionsError::Unauthorized)?; - - let apps = Application::get_all_from_role_id(conn, role_id); - let mut res_vec = Vec::with_capacity(apps.len()); - - for app in apps { - res_vec.push( - ApplicationResponse::from_application(app, conn) - .ok_or(Json(QuestionsError::UserNotFound))?, - ); - } - - Ok(Json(GetApplicationsResponse { - applications: res_vec, - })) - }) - .await -} diff --git a/backend/server/src/schema.jpg b/backend/server/src/schema.jpg deleted file mode 100644 index 6bf1d5aad..000000000 Binary files a/backend/server/src/schema.jpg and /dev/null differ diff --git a/backend/server/src/schema.png b/backend/server/src/schema.png deleted file mode 100644 index d075a240a..000000000 Binary files a/backend/server/src/schema.png and /dev/null differ diff --git a/backend/server/src/service/answer.rs b/backend/server/src/service/answer.rs new file mode 100644 index 000000000..61896e77a --- /dev/null +++ b/backend/server/src/service/answer.rs @@ -0,0 +1,90 @@ +//! Answer service for the Chaos application. +//! +//! This module provides functionality for managing application answers, including: +//! - Verifying answer ownership +//! - Checking if answers can be modified based on application status + +use chrono::Utc; +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user is the owner of an answer. +/// +/// This function checks if the user owns the application that contains the answer. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `answer_id` - The ID of the answer +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is the owner, Unauthorized error otherwise +pub async fn user_is_answer_owner( + user_id: i64, + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM answers ans + JOIN applications app ON ans.application_id = app.id + WHERE ans.id = $1 AND app.user_id = $2 + ) sub + ) + ", + answer_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_owner { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if an answer can be modified. +/// +/// This function checks if the application containing the answer has not been submitted +/// and if the campaign deadline has not passed. +/// +/// # Arguments +/// +/// * `answer_id` - The ID of the answer to check +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the answer can be modified, ApplicationClosed error otherwise +pub async fn assert_answer_application_is_open( + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let time = Utc::now(); + let application = sqlx::query!( + " + SELECT app.submitted, c.ends_at FROM answers ans + JOIN applications app ON app.id = ans.application_id + JOIN campaigns c on c.id = app.campaign_id + WHERE ans.id = $1 + ", + answer_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + if application.submitted || application.ends_at <= time { + return Err(ChaosError::ApplicationClosed) + } + + Ok(()) +} \ No newline at end of file diff --git a/backend/server/src/service/application.rs b/backend/server/src/service/application.rs new file mode 100644 index 000000000..b416306fa --- /dev/null +++ b/backend/server/src/service/application.rs @@ -0,0 +1,134 @@ +//! Application service for the Chaos application. +//! +//! This module provides functionality for managing applications, including: +//! - Verifying application admin privileges +//! - Verifying application ownership +//! - Checking application status and deadlines + +use chrono::Utc; +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for an application. +/// +/// This function checks if the user is an admin of the organisation that owns the campaign +/// the application belongs to. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `application_id` - The ID of the application +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn user_is_application_admin( + user_id: i64, + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT c.organisation_id FROM applications a + JOIN campaigns c on a.campaign_id = c.id + WHERE a.id = $1 + ) ca + JOIN organisation_members m on ca.organisation_id = m.organisation_id + WHERE m.user_id = $2 AND m.role = 'Admin' + ) + ", + application_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if a user is the owner of an application. +/// +/// This function checks if the user created the application. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `application_id` - The ID of the application +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is the owner, Unauthorized error otherwise +pub async fn user_is_application_owner( + user_id: i64, + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM applications WHERE id = $1 AND user_id = $2 + ) sub + ) + ", + application_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_owner { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if an application is still open for submissions. +/// +/// This function checks if the application has not been submitted and if the campaign +/// deadline has not passed. +/// +/// # Arguments +/// +/// * `application_id` - The ID of the application to check +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the application is open, ApplicationClosed error otherwise +pub async fn assert_application_is_open( + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let time = Utc::now(); + let application = sqlx::query!( + " + SELECT submitted, c.ends_at FROM applications a + JOIN campaigns c on c.id = a.campaign_id + WHERE a.id = $1 + ", + application_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + if application.submitted || application.ends_at <= time { + return Err(ChaosError::ApplicationClosed) + } + + Ok(()) +} diff --git a/backend/server/src/service/auth.rs b/backend/server/src/service/auth.rs new file mode 100644 index 000000000..473834b6b --- /dev/null +++ b/backend/server/src/service/auth.rs @@ -0,0 +1,136 @@ +//! Authentication and authorization service for the Chaos application. +//! +//! This module provides functionality for user authentication and authorization, including: +//! - User creation and retrieval +//! - Super user verification +//! - JWT token extraction and validation +//! - User ID extraction from requests + +use crate::models::app::AppState; +use crate::models::error::ChaosError; +use crate::models::user::UserRole; +use crate::service::jwt::decode_auth_token; +use axum::http::request::Parts; +use axum::RequestPartsExt; +use axum_extra::headers::Cookie; +use axum_extra::TypedHeader; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Checks if a user exists in DB based on given email address. If so, their user_id is returned. +/// Otherwise, a new user is created in the DB, and the new id is returned. +/// This function is used in OAuth flows to login/signup users when they click the +/// "Sign in with ___" buttons. The returned user_id will be used to generate a JWT to be +/// used as a token for the user's browser. +/// +/// # Arguments +/// +/// * `email` - The email address of the user +/// * `name` - The name of the user +/// * `pool` - Database connection pool +/// * `snowflake_generator` - Generator for unique user IDs +/// +/// # Returns +/// +/// * `Result` - The user ID if successful, or an error +pub async fn create_or_get_user_id( + email: String, + name: String, + pool: Pool, + snowflake_generator: &mut SnowflakeIdGenerator, +) -> Result { + let possible_user_id = sqlx::query!( + "SELECT id FROM users WHERE lower(email) = $1", + email.to_lowercase() + ) + .fetch_optional(&pool) + .await?; + + if let Some(result) = possible_user_id { + return Ok(result.id); + } + + let user_id = snowflake_generator.real_time_generate(); + + sqlx::query!( + "INSERT INTO users (id, email, name) VALUES ($1, $2, $3)", + user_id, + email.to_lowercase(), + name + ) + .execute(&pool) + .await?; + + Ok(user_id) +} + +/// Verifies if a user has super user privileges. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is a super user, Unauthorized error otherwise +pub async fn assert_is_super_user(user_id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result<(), ChaosError> { + let is_super_user = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND role = $2)", + user_id, + UserRole::SuperUser as UserRole + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_super_user { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Extracts the user ID from an HTTP request using the JWT token in cookies. +/// +/// # Arguments +/// +/// * `parts` - The request parts containing headers and cookies +/// * `state` - The application state containing JWT configuration +/// +/// # Returns +/// +/// * `Result` - The user ID if successful, NotLoggedIn error otherwise +pub async fn extract_user_id_from_request( + parts: &mut Parts, + state: &AppState, +) -> Result { + let decoding_key = &state.decoding_key; + let jwt_validator = &state.jwt_validator; + + + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|e| { + ChaosError::NotLoggedIn + })?; + + + + let token = cookies.get("auth_token").ok_or_else(|| { + ChaosError::NotLoggedIn + })?; + + + + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or_else(|| { + ChaosError::NotLoggedIn + })?; + + + Ok(claims.sub) +} diff --git a/backend/server/src/service/campaign.rs b/backend/server/src/service/campaign.rs new file mode 100644 index 000000000..e8dcd5008 --- /dev/null +++ b/backend/server/src/service/campaign.rs @@ -0,0 +1,84 @@ +//! Campaign service for the Chaos application. +//! +//! This module provides functionality for managing campaigns, including: +//! - Verifying campaign admin privileges +//! - Checking campaign status and deadlines + +use chrono::Utc; +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for a campaign. +/// +/// This function checks if the user is an admin of the organisation that owns the campaign. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `campaign_id` - The ID of the campaign +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn user_is_campaign_admin( + user_id: i64, + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM campaigns c + JOIN organisation_members m on c.organisation_id = m.organisation_id + WHERE c.id = $1 AND m.user_id = $2 AND m.role = 'Admin' + ) + ", + campaign_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if a campaign is still open for applications. +/// +/// This function checks if the campaign deadline has not passed. +/// +/// # Arguments +/// +/// * `campaign_id` - The ID of the campaign to check +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the campaign is open, CampaignClosed error otherwise +pub async fn assert_campaign_is_open( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let time = Utc::now(); + let campaign = sqlx::query!( + " + SELECT ends_at FROM campaigns WHERE id = $1 + ", + campaign_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + if campaign.ends_at <= time { + return Err(ChaosError::CampaignClosed) + } + + Ok(()) +} \ No newline at end of file diff --git a/backend/server/src/service/email_template.rs b/backend/server/src/service/email_template.rs new file mode 100644 index 000000000..2145d049e --- /dev/null +++ b/backend/server/src/service/email_template.rs @@ -0,0 +1,49 @@ +//! Email template service for the Chaos application. +//! +//! This module provides functionality for managing email templates, including: +//! - Verifying email template admin privileges + +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for an email template. +/// +/// This function checks if the user is an admin of the organisation that owns the template. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `template_id` - The ID of the email template +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn user_is_email_template_admin( + user_id: i64, + template_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM email_templates et + JOIN organisation_members m on et.organisation_id = m.organisation_id + WHERE et.id = $1 AND m.user_id = $2 AND m.role = 'Admin' + ) + ", + template_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/jwt.rs b/backend/server/src/service/jwt.rs new file mode 100644 index 000000000..363b59962 --- /dev/null +++ b/backend/server/src/service/jwt.rs @@ -0,0 +1,113 @@ +//! JWT service for the Chaos application. +//! +//! This module provides functionality for JWT token management, including: +//! - Token encoding and decoding +//! - Token payload structure +//! - Token validation + +use jsonwebtoken::DecodingKey; +use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +/// Represents the payload structure of an authorization JWT token. +/// +/// This struct contains all the claims that are encoded in the JWT token, +/// including user information and token validity timestamps. +/// +/// # Fields +/// +/// * `iss` - The issuer of the token (Chaos) +/// * `sub` - The subject of the token (user's ID) +/// * `jti` - A unique identifier for the token +/// * `aud` - The intended audience for the token +/// * `exp` - Token expiration timestamp +/// * `nbf` - Token not-valid-before timestamp +/// * `iat` - Token issued-at timestamp +/// * `username` - The username associated with the token +#[derive(Debug, Deserialize, Serialize)] +pub struct AuthorizationJwtPayload { + pub iss: String, // issuer + pub sub: i64, // subject (user's id) + pub jti: Uuid, // id + pub aud: Vec, // audience (uri the JWT is meant for) + + // Time-based validity + pub exp: i64, // expiry (UNIX timestamp) + pub nbf: i64, // not-valid-before (UNIX timestamp) + pub iat: i64, // issued-at (UNIX timestamp) + + pub username: String, // username +} + +/// Encodes a new authorization JWT token. +/// +/// This function creates a new JWT token with the provided user information +/// and signs it using the provided encoding key. The token is valid for 7 days +/// from the time of creation. +/// +/// # Arguments +/// +/// * `username` - The username to include in the token +/// * `user_id` - The user ID to include in the token +/// * `encoding_key` - The key used to sign the token +/// * `jwt_header` - The header to use for the token +/// +/// # Returns +/// +/// * `String` - The encoded JWT token +/// +/// # Panics +/// +/// This function will panic if: +/// * The system time cannot be retrieved +/// * The token cannot be encoded +pub fn encode_auth_token( + username: String, + user_id: i64, + encoding_key: &EncodingKey, + jwt_header: &Header, +) -> String { + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let expiry = i64::try_from((current_time + Duration::from_secs(604800)).as_secs()).unwrap(); + let claims = AuthorizationJwtPayload { + iss: "Chaos".to_string(), + sub: user_id, + jti: Uuid::new_v4(), + aud: vec!["chaos.devsoc.app".to_string()], + exp: expiry, + nbf: i64::try_from(current_time.as_secs()).unwrap(), + iat: i64::try_from(current_time.as_secs()).unwrap(), + username, + }; + + encode(jwt_header, &claims, encoding_key).unwrap() +} + +/// Decodes and validates an authorization JWT token. +/// +/// This function attempts to decode and validate a JWT token using the provided +/// decoding key and validation parameters. +/// +/// # Arguments +/// +/// * `token` - The JWT token to decode +/// * `decoding_key` - The key used to verify the token's signature +/// * `jwt_validator` - The validation parameters to use +/// +/// # Returns +/// +/// * `Option` - The decoded token payload if valid, None otherwise +pub fn decode_auth_token( + token: &str, + decoding_key: &DecodingKey, + jwt_validator: &Validation, +) -> Option { + let decode_token = decode::(token, decoding_key, jwt_validator); + + match decode_token { + Ok(token) => Option::from(token.claims), + Err(_err) => None::, + } +} diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs new file mode 100644 index 000000000..8a42f1e48 --- /dev/null +++ b/backend/server/src/service/mod.rs @@ -0,0 +1,30 @@ +//! Service layer for the Chaos application. +//! +//! This module contains the business logic layer of the application, handling all core functionality +//! and database operations. Each submodule represents a specific domain of the application: +//! +//! - `answer`: Manages application answers and their data +//! - `application`: Handles application creation, updates, and retrieval +//! - `auth`: Manages authentication and authorization +//! - `campaign`: Handles campaign-related operations +//! - `email_template`: Manages email template operations +//! - `jwt`: Handles JWT token generation and validation +//! - `oauth2`: Manages OAuth2 authentication flow +//! - `offer`: Handles offer creation and management +//! - `organisation`: Manages organisation-related operations +//! - `question`: Handles question management for applications +//! - `rating`: Manages application ratings +//! - `role`: Handles role management within campaigns + +pub mod answer; +pub mod application; +pub mod auth; +pub mod campaign; +pub mod email_template; +pub mod jwt; +pub mod oauth2; +pub mod offer; +pub mod organisation; +pub mod question; +pub mod rating; +pub mod role; diff --git a/backend/server/src/service/oauth2.rs b/backend/server/src/service/oauth2.rs new file mode 100644 index 000000000..f971ea68e --- /dev/null +++ b/backend/server/src/service/oauth2.rs @@ -0,0 +1,54 @@ +//! OAuth2 service for the Chaos application. +//! +//! This module provides functionality for OAuth2 authentication with Google, +//! including client setup and configuration for OpenID Connect. + +use oauth2::basic::BasicClient; +use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; +use std::env; + +/// Builds and configures an OAuth2 client for Google authentication. +/// +/// This function creates a BasicClient configured with Google's OAuth2 endpoints +/// and the application's client credentials. The client follows the OAuth2 standard +/// and OpenID Connect protocol to authenticate users and retrieve their email. +/// +/// # Arguments +/// +/// * `client_id` - The Google OAuth2 client ID +/// * `client_secret` - The Google OAuth2 client secret +/// +/// # Returns +/// +/// * `BasicClient` - A configured OAuth2 client ready for authentication +/// +/// # Panics +/// +/// This function will panic if: +/// * The `CHAOS_HOSTNAME` environment variable is not set +/// * The redirect URL cannot be parsed +/// +/// # Example +/// +/// ```rust +/// let client = build_oauth_client( +/// "your-client-id".to_string(), +/// "your-client-secret".to_string() +/// ); +/// ``` +pub fn build_oauth_client(client_id: String, client_secret: String) -> BasicClient { + let redirect_url = env::var("GOOGLE_REDIRECT_URI").expect("Could not read GOOGLE_REDIRECT_URI"); + + let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()) + .expect("Invalid authorization endpoint URL"); + let token_url = TokenUrl::new("https://www.googleapis.com/oauth2/v3/token".to_string()) + .expect("Invalid token endpoint URL"); + + BasicClient::new( + ClientId::new(client_id), + Some(ClientSecret::new(client_secret)), + auth_url, + Some(token_url), + ) + .set_redirect_uri(RedirectUrl::new(redirect_url).unwrap()) +} diff --git a/backend/server/src/service/offer.rs b/backend/server/src/service/offer.rs new file mode 100644 index 000000000..878093fe9 --- /dev/null +++ b/backend/server/src/service/offer.rs @@ -0,0 +1,80 @@ +//! Offer service for the Chaos application. +//! +//! This module provides functionality for managing offers, including: +//! - Verifying offer admin privileges +//! - Verifying offer recipient status + +use crate::models::error::ChaosError; +use crate::models::offer::Offer; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for an offer. +/// +/// This function checks if the user is an admin of the organisation that owns the campaign +/// the offer belongs to. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `offer_id` - The ID of the offer +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn assert_user_is_offer_admin( + user_id: i64, + offer_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM offers off + JOIN campaigns c ON c.id = off.campaign_id + JOIN organisation_members m on c.organisation_id = m.organisation_id + WHERE off.id = $1 AND m.user_id = $2 AND m.role = 'Admin' + ) + ", + offer_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if a user is the recipient of an offer. +/// +/// This function checks if the user is the intended recipient of the offer. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `offer_id` - The ID of the offer +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is the recipient, Unauthorized error otherwise +pub async fn assert_user_is_offer_recipient( + user_id: i64, + offer_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let offer = Offer::get(offer_id, transaction).await?; + + if offer.user_id != user_id { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/organisation.rs b/backend/server/src/service/organisation.rs new file mode 100644 index 000000000..14deda6dc --- /dev/null +++ b/backend/server/src/service/organisation.rs @@ -0,0 +1,41 @@ +//! Organisation service for the Chaos application. +//! +//! This module provides functionality for managing organisations, including: +//! - Verifying organisation admin privileges + +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for an organisation. +/// +/// This function checks if the user is an admin member of the specified organisation. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `organisation_id` - The ID of the organisation +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn assert_user_is_organisation_admin( + user_id: i64, + organisation_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM organisation_members WHERE organisation_id = $1 AND user_id = $2 AND role = 'Admin')", + organisation_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await?.exists.expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/question.rs b/backend/server/src/service/question.rs new file mode 100644 index 000000000..5ffdea9ea --- /dev/null +++ b/backend/server/src/service/question.rs @@ -0,0 +1,52 @@ +//! Question service for the Chaos application. +//! +//! This module provides functionality for managing campaign questions, including: +//! - Verifying question admin privileges + +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user has admin privileges for a question. +/// +/// This function checks if the user is an admin of the organisation that owns the campaign +/// the question belongs to. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `question_id` - The ID of the question +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn user_is_question_admin( + user_id: i64, + question_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 + FROM questions q + JOIN campaigns c on q.campaign_id = c.id + JOIN organisation_members om on c.organisation_id = om.organisation_id + WHERE q.id = $1 AND om.user_id = $2 AND om.role = 'Admin' + ) + ", + question_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/rating.rs b/backend/server/src/service/rating.rs new file mode 100644 index 000000000..74d62ccff --- /dev/null +++ b/backend/server/src/service/rating.rs @@ -0,0 +1,167 @@ +//! Rating service for the Chaos application. +//! +//! This module provides functionality for managing application ratings, including: +//! - Verifying rating permissions +//! - Checking organisation membership for ratings +//! - Validating rating creator status + +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; + +/// Verifies if a user can review an application based on a rating ID. +/// +/// This function checks if the user is a member of the organisation that owns the campaign +/// the application belongs to. Currently, any member can review applications as they are +/// either directors or execs. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `rating_id` - The ID of the rating +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user can review, Unauthorized error otherwise +/// +/// # Note +/// +/// This behavior might change in the future to be more restrictive. +pub async fn assert_user_is_application_reviewer_given_rating_id( + user_id: i64, + rating_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM organisation_members om + JOIN campaigns c ON om.organisation_id = c.organisation_id + JOIN applications a ON a.campaign_id = c.id + JOIN application_ratings ar ON ar.application_id = a.id + -- Find the organisation that this application rating belongs to + -- (via the campaign the rating belongs to). + WHERE ar.id = $1 + -- Assert user is member of the organisation that owns the campaign + -- this application belongs to. + AND om.user_id = $2 + ) + "#, + rating_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if a user is both the creator of a rating and a current member of the organisation. +/// +/// This function checks if: +/// 1. The user created the rating +/// 2. The user is still a member of the organisation that owns the campaign +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `rating_id` - The ID of the rating +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if both conditions are met, Unauthorized error otherwise +pub async fn assert_user_is_rating_creator_and_organisation_member( + user_id: i64, + rating_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM organisation_members om + JOIN campaigns c ON om.organisation_id = c.organisation_id + JOIN applications a ON a.campaign_id = c.id + JOIN application_ratings ar ON ar.application_id = a.id + -- Find the organisation that this application rating belongs to + -- (via the campaign the rating belongs to). + WHERE ar.id = $1 + -- Assert user is the rater of the application rating. + AND ar.rater_id = $2 + -- Assert user is current member of the organisation that owns the + -- campaign this application belongs to. + AND om.user_id = $2 + ) + "#, + rating_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +/// Verifies if a user is a member of the organisation that owns an application. +/// +/// This function checks if the user is a current member of the organisation that owns +/// the campaign the application belongs to. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `application_id` - The ID of the application +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is a member, Unauthorized error otherwise +pub async fn assert_user_is_organisation_member( + user_id: i64, + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM organisation_members om + JOIN campaigns c ON om.organisation_id = c.organisation_id + JOIN applications a ON a.campaign_id = c.id + -- Find the organisation that this application rating belongs to + -- (via the campaign the rating belongs to). + WHERE a.id = $1 + -- Assert user is current member of the organisation that owns the + -- campaign this application belongs to. + AND om.user_id = $2 + ) + "#, + application_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/role.rs b/backend/server/src/service/role.rs new file mode 100644 index 000000000..1e68541e0 --- /dev/null +++ b/backend/server/src/service/role.rs @@ -0,0 +1,54 @@ +//! Role service for the Chaos application. +//! +//! This module provides functionality for managing campaign roles, including: +//! - Verifying role admin privileges + +use std::ops::DerefMut; +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres, Transaction}; + +/// Verifies if a user has admin privileges for a role. +/// +/// This function checks if the user is an admin of the organisation that owns the campaign +/// the role belongs to. +/// +/// # Arguments +/// +/// * `user_id` - The ID of the user to check +/// * `role_id` - The ID of the role +/// * `pool` - Database connection pool +/// +/// # Returns +/// +/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise +pub async fn user_is_role_admin( + user_id: i64, + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT c.organisation_id FROM campaign_roles r + JOIN campaigns c on r.campaign_id = c.id + WHERE r.id = $1 + ) cr + JOIN organisation_members m on cr.organisation_id = m.organisation_id + WHERE m.user_id = $2 AND m.role = 'Admin' + ) + ", + role_id, + user_id + ) + .fetch_one(transaction.deref_mut()) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/src/database/schema.rs b/backend/server/src/src/database/schema.rs deleted file mode 100644 index f138f6d23..000000000 --- a/backend/server/src/src/database/schema.rs +++ /dev/null @@ -1,174 +0,0 @@ -// @generated automatically by Diesel CLI. - -pub mod sql_types { - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "admin_level"))] - pub struct AdminLevel; - - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "application_status"))] - pub struct ApplicationStatus; - - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "user_gender"))] - pub struct UserGender; -} - -diesel::table! { - answers (id) { - id -> Int4, - application_id -> Int4, - question_id -> Int4, - description -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::ApplicationStatus; - - applications (id) { - id -> Int4, - user_id -> Int4, - role_id -> Int4, - status -> ApplicationStatus, - created_at -> Timestamp, - updated_at -> Timestamp, - private_status -> Nullable, - } -} - -diesel::table! { - campaigns (id) { - id -> Int4, - organisation_id -> Int4, - name -> Text, - cover_image -> Nullable, - description -> Text, - starts_at -> Timestamp, - ends_at -> Timestamp, - published -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - comments (id) { - id -> Int4, - application_id -> Int4, - commenter_user_id -> Int4, - description -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::AdminLevel; - - organisation_users (id) { - id -> Int4, - user_id -> Int4, - organisation_id -> Int4, - admin_level -> AdminLevel, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - organisations (id) { - id -> Int4, - name -> Text, - logo -> Nullable, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - questions (id) { - id -> Int4, - role_ids -> Array>, - title -> Text, - description -> Nullable, - max_bytes -> Int4, - required -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - ratings (id) { - id -> Int4, - application_id -> Int4, - rater_user_id -> Int4, - rating -> Int4, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - roles (id) { - id -> Int4, - campaign_id -> Int4, - name -> Text, - description -> Nullable, - min_available -> Int4, - max_available -> Int4, - finalised -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - } -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::UserGender; - - users (id) { - id -> Int4, - email -> Text, - zid -> Text, - display_name -> Text, - degree_name -> Text, - degree_starting_year -> Int4, - superuser -> Bool, - created_at -> Timestamp, - updated_at -> Timestamp, - gender -> UserGender, - pronouns -> Text, - } -} - -diesel::joinable!(answers -> applications (application_id)); -diesel::joinable!(answers -> questions (question_id)); -diesel::joinable!(applications -> roles (role_id)); -diesel::joinable!(applications -> users (user_id)); -diesel::joinable!(campaigns -> organisations (organisation_id)); -diesel::joinable!(comments -> applications (application_id)); -diesel::joinable!(comments -> users (commenter_user_id)); -diesel::joinable!(organisation_users -> organisations (organisation_id)); -diesel::joinable!(organisation_users -> users (user_id)); -diesel::joinable!(ratings -> applications (application_id)); -diesel::joinable!(ratings -> users (rater_user_id)); -diesel::joinable!(roles -> campaigns (campaign_id)); - -diesel::allow_tables_to_appear_in_same_query!( - answers, - applications, - campaigns, - comments, - organisation_users, - organisations, - questions, - ratings, - roles, - users, -); diff --git a/backend/server/src/state/mod.rs b/backend/server/src/state/mod.rs deleted file mode 100644 index 8a83510af..000000000 --- a/backend/server/src/state/mod.rs +++ /dev/null @@ -1,66 +0,0 @@ -use dotenv; -use jsonwebtoken::{DecodingKey, EncodingKey}; -use once_cell::sync::OnceCell; -use reqwest::Client; -use serde_json::Value; - -const GOOGLE_OPENID_DISCOVERY_URL: &str = - "https://accounts.google.com/.well-known/openid-configuration"; -const GOOGLE_OPENID_USERINFO_KEY: &str = "userinfo_endpoint"; - -pub struct GlobalStringRef {} - -static INSTANCE: OnceCell = OnceCell::new(); - -impl GlobalStringRef { - pub fn global() -> &'static String { - INSTANCE.get().expect("String not initialized") - } -} - -pub struct ApiState { - pub reqwest_client: Client, - pub jwt_encoding_key: EncodingKey, - pub jwt_decoding_key: DecodingKey, - pub userinfo_endpoint: String, -} - -pub async fn api_state() -> ApiState { - let reqwest_client = Client::new(); - - let jwt_secret = dotenv::var("JWT_SECRET").expect("JWT_SECRET should be in env"); - - INSTANCE - .set(jwt_secret) - .expect("Failed to initialize jwt_secret"); - - let discovery = reqwest::get(GOOGLE_OPENID_DISCOVERY_URL) - .await - .expect(&format!( - "Failed to fetch openid discovery from {:?}", - GOOGLE_OPENID_DISCOVERY_URL - )) - .json::>() - .await - .expect("Failed to parse openid discovery as a JSON Object"); - - let userinfo_endpoint = match discovery.get(GOOGLE_OPENID_USERINFO_KEY).expect(&format!( - "Openid discovery does not contain a {:?} key", - GOOGLE_OPENID_USERINFO_KEY - )) { - Value::String(url) => url.to_string(), - other => panic!( - "Openid discovery {:?} key has incorrect type {:?}", - GOOGLE_OPENID_USERINFO_KEY, other - ), - }; - - let api_state = ApiState { - reqwest_client, - jwt_encoding_key: EncodingKey::from_secret(GlobalStringRef::global().as_bytes()), - jwt_decoding_key: DecodingKey::from_secret(GlobalStringRef::global().as_bytes()), - userinfo_endpoint, - }; - - api_state -} diff --git a/backend/server/src/static_resources.rs b/backend/server/src/static_resources.rs deleted file mode 100644 index a1df8a5b8..000000000 --- a/backend/server/src/static_resources.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::path::{Path, PathBuf}; - -use rocket::{ - fs::NamedFile, - get, - response::{self, Responder}, - Request, Response, -}; - -pub struct CachedFile(NamedFile); - -impl<'r> Responder<'r, 'r> for CachedFile { - fn respond_to(self, req: &Request) -> response::Result<'r> { - Response::build_from(self.0.respond_to(req)?) - .raw_header("Cache-control", "max-age=86400") // 24h (24*60*60) - .ok() - } -} - -#[get("/images/")] -pub async fn files(file: PathBuf) -> Option { - NamedFile::open(Path::new("images/").join(file)) - .await - .ok() - .map(|nf| CachedFile(nf)) -} diff --git a/backend/server/src/user.rs b/backend/server/src/user.rs deleted file mode 100644 index 21902bfda..000000000 --- a/backend/server/src/user.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::database::{ - models::{Application, Campaign, OrganisationUser, User}, - Database, -}; -use crate::error::JsonErr; -use diesel::PgConnection; -use rocket::{ - get, - http::Status, - serde::{json::Json, Serialize}, -}; - -#[derive(Serialize)] -pub struct UserResponse { - email: String, - zid: String, - display_name: String, - degree_name: String, - degree_starting_year: i32, -} - -#[derive(Serialize)] -pub enum UserError { - UserNotFound, - CampaignNotFound, - PermissionDenied, -} - -fn user_is_boss(boss_user: &User, user: &User, conn: &PgConnection) -> bool { - if boss_user.id == user.id { - return true; - } - - let apps = Application::get_all_from_user_id(conn, user.id); - for app in &apps { - let boss_mode = OrganisationUser::role_admin_level(app.role_id, boss_user.id, conn) - .is_at_least_director() - .check() - .is_ok(); - if boss_mode { - return true; - } - } - false -} - -#[get("/")] -pub async fn get(user: User) -> Json { - Json(UserResponse { - email: user.email, - zid: user.zid, - display_name: user.display_name, - degree_name: user.degree_name, - degree_starting_year: user.degree_starting_year, - }) -} - -#[get("/")] -pub async fn get_user( - user_id: i32, - user: User, - db: Database, -) -> Result, JsonErr> { - db.run(move |conn| { - let res = User::get_from_id(&conn, user_id) - .ok_or(JsonErr(UserError::UserNotFound, Status::NotFound))?; - - if user_is_boss(&user, &res, conn) { - Ok(Json(UserResponse { - email: user.email, - zid: user.zid, - display_name: user.display_name, - degree_name: user.degree_name, - degree_starting_year: user.degree_starting_year, - })) - } else { - Err(JsonErr(UserError::PermissionDenied, Status::Forbidden)) - } - }) - .await -} - -#[get("/campaigns")] -pub async fn get_user_campaigns(user: User, db: Database) -> Json> { - let campaigns = db.run(move |conn| user.get_all_campaigns(conn)).await; - - Json(campaigns) -} diff --git a/backend/setup-dev-env.sh b/backend/setup-dev-env.sh new file mode 100755 index 000000000..b64756c27 --- /dev/null +++ b/backend/setup-dev-env.sh @@ -0,0 +1,85 @@ +#!/bin/sh + +# Drop the caller into a new shell that has the required dependencies, namely +# postgres and sqlx, installed and running. This is required because the Rust +# backend can only be built and run if the database is also running, due to +# sqlx. + +# Write .env file to temporary file. +tmp_env_file="$(mktemp)" +trap 'rm -rf "$tmp_env_file"' EXIT INT TERM +cat << 'EOF' > "$tmp_env_file" +DATABASE_URL="postgres://user:password@localhost:5432/chaos" +JWT_SECRET="test_secret" +GOOGLE_CLIENT_ID="test" +GOOGLE_CLIENT_SECRET="test" +GOOGLE_REDIRECT_URI="http://localhost:3000/auth/callback" +S3_BUCKET_NAME="chaos-storage" +S3_ACCESS_KEY="test_access_key" +S3_SECRET_KEY="test_secret_key" +S3_ENDPOINT="https://chaos-storage.s3.ap-southeast-1.amazonaws.com" +S3_REGION_NAME="ap-southeast-1" +DEV_ENV="dev" +SMTP_USERNAME="test_username" +SMTP_PASSWORD="test_password" +SMTP_HOST="smtp.example.com" +EOF + +# Check the user has all required tools installed. +for cmd in "which cargo" "which docker && docker info" "which docker-compose || docker compose"; do + if ! eval "$cmd" 1>/dev/null 2>&1; then + echo "The command '$cmd' failed, indicating you might not have that tool installed." >&2 + exit 1 + fi +done + +# Create .env file. +env_file=.env + +# The .env file already exists. +if [ -f "$env_file" ]; then + # If existing env file differs from new one, save the existing env file to a backup file. + if ! diff "$env_file" "$tmp_env_file" > /dev/null; then + backup_env_file="$env_file" + while [ -f "$backup_env_file" ]; do + # Append `.backup` to backup filename until we find a filename that doesn't exist yet. + backup_env_file="$backup_env_file.backup" + done + + echo "Saving existing env file '$env_file' to backup env file '$backup_env_file'" + mv "$env_file" "$backup_env_file" + fi +fi + +echo "Overwriting $env_file file" +cp "$tmp_env_file" "$env_file" + +# Install sqlx if it isn't installed yet. +if ! which sqlx >/dev/null; then + echo "Installing sqlx" + cargo install sqlx-cli --no-default-features --features native-tls,postgres +else + echo "sqlx already installed" +fi + +# Run postgres database in the background. +this_script_dir="$(dirname "$(realpath "$0")")" +docker_compose_file_name="setup-test-database.yml" +docker_compose_file_path="$this_script_dir/$docker_compose_file_name" + +echo 'Starting up postgres database in docker' +docker-compose -f "$docker_compose_file_path" up --detach || exit 1 + +# Ensure the docker container gets killed once this script exits. +trap 'echo "shutting down $docker_compose_file_path" && docker-compose -f "$docker_compose_file_path" down' EXIT INT TERM + +# Wait for the database to be ready. +echo "Waiting for database to be ready" +sleep 3 + +# Setup sqlx. +echo "Setting up sqlx" +sqlx database create || exit 1 +sqlx migrate run || exit 1 + +"$SHELL" diff --git a/backend/setup-test-database.yml b/backend/setup-test-database.yml new file mode 100644 index 000000000..d30d0b4e4 --- /dev/null +++ b/backend/setup-test-database.yml @@ -0,0 +1,17 @@ +# Run `docker-compose -f setup-test-database.yml up` + +services: + database: + # Official Postgres image from DockerHub. + image: 'postgres:16' + + # By default, a Postgres database is running on port 5432, so make that port accessible from outside of the container. + ports: + - 5432:5432 + + environment: + # Username and password to access database. + POSTGRES_USER: user + POSTGRES_PASSWORD: password + # Name of the database. + POSTGRES_DB: chaos diff --git a/frontend/.env.development b/frontend/.env.development index 6cc349079..de14f4b12 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,3 @@ -VITE_OAUTH_CALLBACK_URL=https://accounts.google.com/o/oauth2/v2/auth?client_id=985448402284-al4vuqpokkhgv6h952lhu6iasg1lupug.apps.googleusercontent.com&redirect_uri=http://localhost:3000/auth/callback&response_type=code&scope=profile email&access_type=online -VITE_API_BASE_URL=http://localhost:8000 +VITE_OAUTH_CALLBACK_URL=https://accounts.google.com/o/oauth2/v2/auth?client_id=731862014126-5b109p4v6b173910ib347gtfn0ecnacj.apps.googleusercontent.com&redirect_uri=http://localhost:8080/api/auth/callback/google&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile&access_type=offline +VITE_API_BASE_URL=http://localhost:8080 BROWSER=none diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 2a2ef1315..ef9d6e1ce 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -25,7 +25,8 @@ "parserOptions": { "ecmaVersion": 12, "sourceType": "module", - "project": "./tsconfig.json" + "project": ["./tsconfig.json", "./tsconfig.node.json"], + "tsconfigRootDir": "./" }, "plugins": ["react", "prettier"], "rules": { diff --git a/frontend/.gitignore b/frontend/.gitignore index 7e5993ce8..881d48186 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +.env.development +.env \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 000000000..1d282e640 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 948097a63..c89d3cf10 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,7 @@ content="iVGfVNkqvkNxy6yl6RKaGIok_5_DOndLxzj9ydHWJrA" /> + { const [AppBarTitle, setNavBarTitle] = useState(""); return ( - - - + + - +
}> {routes} - +
{ />
-
-
+ + ); }; diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 4cfa7fed3..36c9bf569 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -43,7 +43,10 @@ const API = { jsonBody = true, jsonResp = true, }: Params): Promise => { - const endpoint = new URL(`${window.origin}/api${path}`); + // Ensure path doesn't start with a slash to avoid double slashes + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + // Use direct backend URL instead of proxy + const endpoint = new URL(`http://localhost:8080/api/${cleanPath}`); endpoint.search = new URLSearchParams(queries).toString(); const payload: Payload = { @@ -56,12 +59,26 @@ const API = { }; if (method !== "GET") payload.body = jsonBody ? JSON.stringify(body) : body; - const resp = await fetch(endpoint, payload); + const resp = await fetch(endpoint, { + ...payload, + credentials: 'include', // Include cookies + }); + if (!resp.ok) { let data; try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data = await resp.json(); + // Use the same regex approach for error responses + const text = await resp.text(); + const processedText = text.replace(/"organisation_id":(\d{16,})/g, '"organisation_id":"$1"') + .replace(/"id":(\d{16,})/g, '"id":"$1"') + .replace(/"campaign_id":(\d{16,})/g, '"campaign_id":"$1"') + .replace(/"user_id":(\d{16,})/g, '"user_id":"$1"') + .replace(/"role_id":(\d{16,})/g, '"role_id":"$1"') + .replace(/"application_id":(\d{16,})/g, '"application_id":"$1"') + .replace(/"question_id":(\d{16,})/g, '"question_id":"$1"') + .replace(/"rater_id":(\d{16,})/g, '"rater_id":"$1"') + .replace(/"commenter_user_id":(\d{16,})/g, '"commenter_user_id":"$1"'); + data = JSON.parse(processedText); } catch (e) { // just let data be undefined } @@ -69,7 +86,21 @@ const API = { } if (jsonResp) { - return resp.json() as Promise; + // Parse JSON manually to preserve large integers + const text = await resp.text(); + + // Use regex to replace large integers with strings before parsing + const processedText = text.replace(/"organisation_id":(\d{16,})/g, '"organisation_id":"$1"') + .replace(/"id":(\d{16,})/g, '"id":"$1"') + .replace(/"campaign_id":(\d{16,})/g, '"campaign_id":"$1"') + .replace(/"user_id":(\d{16,})/g, '"user_id":"$1"') + .replace(/"role_id":(\d{16,})/g, '"role_id":"$1"') + .replace(/"application_id":(\d{16,})/g, '"application_id":"$1"') + .replace(/"question_id":(\d{16,})/g, '"question_id":"$1"') + .replace(/"rater_id":(\d{16,})/g, '"rater_id":"$1"') + .replace(/"commenter_user_id":(\d{16,})/g, '"commenter_user_id":"$1"'); + + return JSON.parse(processedText) as T; } return undefined as T; }, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b7841706e..e03b4bf35 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,28 +4,37 @@ import { getStore } from "../utils"; import API from "./api"; -import type { - AdminLevel, - Application, - ApplicationAnswer, - ApplicationRating, - ApplicationResponse, - ApplicationStatus, - AuthenticateResponse, - Campaign, - CampaignWithRoles, - NewCampaignInput, - Organisation, - OrganisationInfo, - QuestionInput, - QuestionResponse, - Role, - RoleApplications, - RoleInput, - UserGender, - UserResponse, +import { + type Application, + type ApplicationAnswer, + type ApplicationRating, + type ApplicationResponse, + type ApplicationStatus, + type AuthenticateResponse, + type Campaign, + type CampaignWithRoles, + type Member, + type NewCampaignInput, + type Organisation, + type OrganisationInfo, + type OrganisationRole, + type QuestionInput, + type QuestionResponse, + type Role, + type RoleApplications, + type RoleInput, + type UserGender, + type User, + type newOrganisation, + type NewApplication, + type ApplicationDetails, + type NewRating, + type Answer, + QuestionType, + AnswerData, } from "../types/api"; +// todo: update to new route export const authenticate = async (oauthToken: string) => API.request({ method: "POST", @@ -35,6 +44,7 @@ export const authenticate = async (oauthToken: string) => }, }); +// todo: update to new route export const doSignup = async ({ name, degree_name, @@ -78,95 +88,112 @@ export const doSignup = async ({ // }; // }; +// Cookies-based approach - HTTP-only cookies are automatically sent with requests const authenticatedRequest = ( payload: Parameters>[0] ) => { - const token = getStore("AUTH_TOKEN"); - if (!token) { - throw new Error("No token found"); - } - - return API.request({ - ...payload, - header: { - Authorization: `Bearer ${token}`, - ...payload.header, - }, - }); + // With HTTP-only cookies, we don't need to manually add the Authorization header + // The cookie will be automatically sent with the request + return API.request(payload); }; +// todo: update to new route export const getAllCampaigns = () => - authenticatedRequest<{ - current_campaigns: CampaignWithRoles[]; - past_campaigns: CampaignWithRoles[]; - }>({ - path: "/campaign/all", + authenticatedRequest({ + path: "/v1/campaigns", + }); + +export const getAdminData = (organisationId: string) => + authenticatedRequest<{ members: Member[] }>({ + path: `/v1/organisation/${organisationId}/admin`, }); -export const isAdminInOrganisation = (orgId: number) => - authenticatedRequest({ path: `/organisation/${orgId}/is_admin` }); +// todo: create backend route + update referencing components +export const getAdminOrgs = () => + authenticatedRequest({ + path: "/v1/user/organisations", + }); -export const getAdminData = () => - authenticatedRequest<{ organisations: OrganisationInfo[] }>({ - path: "/admin", +export const getOrganisation = (organisationId: string) => + authenticatedRequest({ + path: `/v1/organisation/${organisationId}`, }); -export const getOrganisation = (organisationId: number) => +export const getOrganisationBySlug = (organisationSlug: string) => authenticatedRequest({ - path: `/organisation/${organisationId}`, + path: `/v1/organisation/slug/${organisationSlug}`, }); -export const createOrganisation = (name: string) => +// todo: update all referencing components +export const createOrganisation = (orgData: newOrganisation) => authenticatedRequest({ method: "POST", - path: "/organisation/", - body: { name }, + path: "/v1/organisation/", + body: { orgData }, }); -export const putOrgLogo = (orgId: number, logo: File) => - authenticatedRequest({ - method: "PUT", - path: `/organisation/${orgId}/logo`, - body: logo, - jsonBody: false, +export const putOrgLogo = async (orgId: string, logo: File) => { + const presignedUrl = await authenticatedRequest({ + method: "PATCH", + path: `/v1/organisation/${orgId}/logo` + }); + + return fetch(presignedUrl, { + method: 'PUT', + headers: { + 'Content-Type': logo.type, + }, + body: logo }); +} -export const newApplication = (roleId: number) => +export const newApplication = (campaignId: number, newApp: NewApplication) => authenticatedRequest({ method: "POST", - path: "/application/", - body: { role_id: roleId }, + path: `/v1/campaign/${campaignId}/application/`, + body: newApp, }); -export const doDeleteOrg = (orgId: number) => +export const doDeleteOrg = (orgId: string) => authenticatedRequest({ method: "DELETE", - path: `/organisation/${orgId}`, + path: `/v1/organisation/${orgId}`, jsonResp: false, }); export const getCampaign = (campaignId: number) => - authenticatedRequest({ path: `/campaign/${campaignId}` }); + authenticatedRequest({ path: `/v1/campaign/${campaignId}` }); + +export const getCampaignBySlugs = (organisationSlug: string, campaignSlug: string) => + authenticatedRequest({ + path: `/v1/campaign/slug/${organisationSlug}/${campaignSlug}`, + }); export const getCampaignRoles = (campaignId: number) => - authenticatedRequest<{ roles: Role[] }>({ - path: `/campaign/${campaignId}/roles`, + authenticatedRequest({ + path: `/v1/campaign/${campaignId}/roles`, }); export const getRoleApplications = (roleId: number) => - authenticatedRequest({ - path: `/role/${roleId}/applications`, + authenticatedRequest({ + path: `/v1/role/${roleId}/applications`, }); -export const getRoleQuestions = (roleId: number) => - authenticatedRequest<{ questions: QuestionResponse[] }>({ - path: `/role/${roleId}/questions`, +export const getRoleQuestions = (campaignId: number, roleId: number) => + authenticatedRequest({ + path: `/v1/campaign/${campaignId}/role/${roleId}/questions`, }); -export const setApplicationRating = (applicationId: number, rating: number) => +// todo: update all referencing components +export const getCommonQuestions = (campaignID: number) => + authenticatedRequest({ + path: `/v1/campaign/${campaignID}/questions/common` + }); + +export const setApplicationRating = (applicationId: number, rating: NewRating) => authenticatedRequest({ method: "PUT", - path: `/application/${applicationId}/rating`, + path: `/v1/${applicationId}/rating`, body: { rating, }, @@ -174,34 +201,41 @@ export const setApplicationRating = (applicationId: number, rating: number) => }); export const getSelfInfo = () => - authenticatedRequest({ path: "/user" }); + authenticatedRequest({ path: "/v1/user" }); + +export const getApplicationAnswers = (applicationId: number, roleId: number) => + authenticatedRequest({ + path: `/v1/application/${applicationId}/answers/role/${roleId}`, + }); -export const getApplicationAnswers = (applicationId: number) => - authenticatedRequest<{ answers: ApplicationAnswer[] }>({ - path: `/application/${applicationId}/answers`, +export const getCommonApplicationAnswers = (applicationId: number) => + authenticatedRequest({ + path: `/v1/application/${applicationId}/answers/common`, }); export const getApplicationRatings = (applicationId: number) => authenticatedRequest<{ ratings: ApplicationRating[] }>({ - path: `/application/${applicationId}/ratings`, + path: `/v1/${applicationId}/ratings`, }); +// todo: update all referencing components export const submitAnswer = ( applicationId: number, questionId: number, - description: string + answerData: AnswerData ) => authenticatedRequest({ method: "POST", - path: `/application/answer/`, + path: `/v1/application/${applicationId}/answer/`, body: { application_id: applicationId, question_id: questionId, - description, + answer_data: answerData, }, jsonResp: false, }); +// todo: update to new route export const createCampaign = ( campaign: NewCampaignInput, roles: RoleInput[], @@ -209,40 +243,44 @@ export const createCampaign = ( ) => authenticatedRequest({ method: "POST", - path: "/campaign", + path: `/v1/organisation/${campaign.organisation_id}/campaign`, body: { campaign, roles, questions }, }); +// todo: update to new route export const setCampaignCoverImage = (campaignId: number, cover_image: File) => authenticatedRequest({ - method: "PUT", - path: `/campaign/${campaignId}/cover_image`, + method: "PATCH", + path: `/v1/campaign/${campaignId}/banner`, body: cover_image, jsonBody: false, }); +// todo: update to new route export const deleteCampaign = (id: number) => authenticatedRequest({ method: "DELETE", - path: `/campaign/${id}`, + path: `/v1/campaign/${id}`, jsonResp: false, }); +// todo: update to new route export const setApplicationStatus = ( applicationId: number, status: ApplicationStatus ) => authenticatedRequest({ - method: "PUT", - path: `/application/${applicationId}/status`, + method: "PATCH", + path: `/v1/application/${applicationId}/status`, body: status, jsonResp: false, }); +// todo: update to new route export const inviteUserToOrg = ( email: string, - organisationId: number, - adminLevel: AdminLevel = "ReadOnly" + organisationId: string, + adminLevel: OrganisationRole = "User" ) => authenticatedRequest({ method: "POST", @@ -253,3 +291,68 @@ export const inviteUserToOrg = ( }, jsonResp: false, }); + +/** + * helper function to retrieve list of all answers in every application, + * retrieving answer option text for question types with predefined answers such as + * multiple choice. + * + * first layer of [] -> list of applications + * second layer pf [] -> list of questions/answers + * third (optional) layer of [] -> list of selected answers for multiselect questions + * + * @param applications + * @param campaignId + * @param roleId + * @returns string[][][] or string[][] + */ +export const getAnsweredApplicationQuestions = (applications: ApplicationDetails[], campaignId: number, roleId: number) => { + return Promise.all( + applications.map(async (application) => { + const roleAnswers = await getApplicationAnswers(application.id, roleId); + const commonAnswers = await getCommonApplicationAnswers(application.id); + + const roleQuestions = await getRoleQuestions(campaignId, roleId); + const commonQuestions = await getCommonQuestions(campaignId); + + const completeAnswers = [...commonAnswers, ...roleAnswers]; + const completeQuestions = [...commonQuestions, ...roleQuestions]; + + return completeAnswers.map((answer) => { + switch (answer.answer_type) { + case QuestionType.ShortAnswer: + return answer.data as string; + + case QuestionType.MultiChoice: + // search question list for questions of Multichoice type + return completeQuestions.find(question => + question.questionType === QuestionType.MultiChoice && + // then search that question's multichoice options for the option which was selected + question.data.some(data => data.options.id === answer.data + ))?.data.find(data => data.options.id === answer.data)?.options.text; // return the text of the actual question option + + case QuestionType.MultiSelect: + return completeQuestions.find(question => + question.questionType === QuestionType.MultiSelect && + question.data.some(data => Array.isArray(answer.data) && answer.data.includes(data.options.id) + ))?.data.filter(data => Array.isArray(answer.data) && answer.data.includes(data.options.id)) + .map(data => data.options.text); // return list of text of selected options + + case QuestionType.DropDown: + return completeQuestions.find(question => + question.questionType === QuestionType.DropDown && + question.data.some(data => data.options.id === answer.data + ))?.data.find(data => data.options.id === answer.data) + ?.options.text; + + case QuestionType.Ranking: + return completeQuestions.find(question => + question.questionType === QuestionType.Ranking && + question.data.some(data => Array.isArray(answer.data) && answer.data.includes(data.options.id) + ))?.data.filter(data => Array.isArray(answer.data) && answer.data.includes(data.options.id)) + .map(data => data.options.text); + } + }); + }) + ); +} \ No newline at end of file diff --git a/frontend/src/components/AdminSideBar/adminSidebar.styled.ts b/frontend/src/components/AdminSideBar/adminSidebar.styled.ts index 65c3ea8cd..65789fd87 100644 --- a/frontend/src/components/AdminSideBar/adminSidebar.styled.ts +++ b/frontend/src/components/AdminSideBar/adminSidebar.styled.ts @@ -1,80 +1,34 @@ import AddIcon from "@mui/icons-material/Add"; import RemoveIcon from "@mui/icons-material/Remove"; -import { ToggleButton, ToggleButtonGroup } from "@mui/material"; -import { styled } from "@mui/material/styles"; +import * as ToggleGroup from "@radix-ui/react-toggle-group"; +import tw, { styled } from "twin.macro"; -export const SidebarContainer = styled("div")<{ - isFormOpen: boolean; - sidebarWidth: string; -}>(({ isFormOpen, sidebarWidth }) => ({ - position: "relative", - width: isFormOpen ? "280px" : sidebarWidth, - height: "100%", - backgroundColor: "#f0f4fc", - transition: "0.2s", - borderRightWidth: "1px", - borderRightStyle: "solid", - borderColor: "grey", - overflow: "hidden", -})); +export const SidebarContainer = tw.div`relative h-full w-[80px] overflow-hidden border-r border-solid border-gray-300 bg-[#f0f4fc] transition-all duration-200 hover:w-[280px]`; -export const OrgButtonGroup = styled(ToggleButtonGroup)` - width: 100%; - padding: 0px; - margin: 0px; -`; +export const OrgButtonGroup = styled(ToggleGroup.Root, { + ...tw`m-0 w-full divide-y divide-gray-300 border-b border-t border-gray-300 p-0`, +}); -export const OrgButton = styled(ToggleButton)` - position: relative; - display: table; - width: 100%; - list-style: none; - height: 90px; - padding: 5px; - vertical-align: middle; -`; +export const OrgButton = styled(ToggleGroup.Item, { + ...tw`relative table h-[90px] w-full cursor-pointer list-none bg-transparent p-[5px] align-middle font-medium uppercase text-[#000000aa] transition-colors hover:bg-gray-200 hover:text-[#000000] data-[state='on']:bg-gray-200`, +}); -export const OrgButtonContent = styled("div")` - display: flex; - padding: 4px; -`; +export const OrgButtonContent = tw.div`flex p-1`; -export const CreateOrgButton = styled(OrgButton)` - height: 90px; - border-bottom: 0; -`; +export const CreateOrgButton = styled("button", { + ...tw`h-[90px] w-full border-b-0 p-[5px] hover:bg-gray-200 hover:text-[#000000]`, +}); -export const CreateOrgIcon = styled(AddIcon)` - font-size: 30px; - margin: 14px; -`; +export const CreateOrgIcon = styled(AddIcon, { + ...tw`m-[14px] text-[30px]`, +}); -export const RemoveOrgIcon = styled(RemoveIcon)` - font-size: 30px; - margin: 14px; -`; +export const RemoveOrgIcon = styled(RemoveIcon, { + ...tw`m-[14px] text-[30px]`, +}); -export const OrgIcon = styled("span")` - display: block; - min-width: 60px; - height: 60px; - line-height: 60px; - margin: 0px; -`; +export const OrgIcon = tw.span`m-0 block h-[60px] min-w-[60px] leading-[60px]`; -export const OrgIconImage = styled("img")` - width: 60px; - height: 60px; - border-radius: 12px; -`; +export const OrgIconImage = tw.img`h-[60px] w-[60px] rounded-[12px]`; -export const OrgName = styled("span")` - position: relative; - display: block; - padding: 0 10px; - height: 60px; - line-height: 60px; - text-align: start; - white-space: nowrap; - padding-left: 25px; -`; +export const OrgName = tw.span`relative block h-[60px] whitespace-nowrap p-0 px-[10px] pl-[25px] text-left leading-[60px]`; diff --git a/frontend/src/components/AdminSideBar/index.tsx b/frontend/src/components/AdminSideBar/index.tsx index 2d11e5c23..e5ba4d49b 100644 --- a/frontend/src/components/AdminSideBar/index.tsx +++ b/frontend/src/components/AdminSideBar/index.tsx @@ -1,4 +1,5 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; +import tw from "twin.macro"; import { pushToast } from "utils"; @@ -28,8 +29,6 @@ type Props = { setOrgSelected: (orgSelected: number) => void; isFormOpen: boolean; setIsFormOpen: (isFormOpen: boolean) => void; - sidebarWidth: string; - setSidebarWidth: (sidebarWidth: string) => void; }; const AdminSidebar = ({ @@ -39,8 +38,6 @@ const AdminSidebar = ({ setOrgSelected, isFormOpen, setIsFormOpen, - sidebarWidth, - setSidebarWidth, }: Props) => { const [uploadedImage, setUploadedImage] = useState<{ image: File | null; @@ -112,24 +109,22 @@ const AdminSidebar = ({ return ( setSidebarWidth("280px")} - onMouseOut={() => setSidebarWidth("80px")} + css={{ + ...(isFormOpen ? tw`w-[280px]` : tw`w-[80px]`), + }} > setIsFormOpen(!isFormOpen)}> {isFormOpen ? : } - New Organisation + New Organisation {isFormOpen && ( ( setOrgSelected(idx)} > diff --git a/frontend/src/components/ApplicationForm/applicationForm.styled.ts b/frontend/src/components/ApplicationForm/applicationForm.styled.ts deleted file mode 100644 index c5a05558a..000000000 --- a/frontend/src/components/ApplicationForm/applicationForm.styled.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - Box, - Card, - Container, - TextField, - ToggleButton, - Typography, -} from "@mui/material"; -import TableCell from "@mui/material/TableCell"; -import { styled } from "@mui/material/styles"; -import { NavLink } from "react-router-dom"; - -export const CampaignImageCard = styled(Card)` - width: 100%; - border-radius: 12px; - margin-top: 50px; - box-shadow: 15; -`; - -export const CampaignDescription = styled(Box)` - width: 100%; - text-align: center; - padding-top: 50px; - padding-bottom: 50px; -`; - -export const UserInfoCell = styled(TableCell)` - font-size: 20px; - border-bottom: none; - padding: 12px; -`; - -export const UserInfo = styled("div")` - display: flex; -`; - -export const UserInfoFields = styled("div")` - width: 15%; - float: left; -`; - -export const UserInfoTypography = styled(Typography)` - font-size: 20px; - padding: 10px; -`; - -export const AuthLink = styled(NavLink)` - text-decoration: none; -`; - -export const FormContainer = styled(Container)` - display: flex; - justify-content: center; - width: 100%; -`; - -export const FormContent = styled("div")` - display: flex; - max-width: 700px; - width: 90%; - flex-direction: column; -`; - -export const Section = styled("div")<{ isHidden?: boolean }>( - ({ isHidden }) => ({ - marginTop: "0px", - marginBottom: "50px", - display: isHidden ? "none" : "", - }) -); - -export const SectionHeader = styled("h1")` - margin-left: 10px; - margin-top: 0px; -`; - -export const RoleButton = styled(ToggleButton)` - &.Mui-selected, - &.Mui-selected:hover { - color: white; - background-color: #084cec; - } - border-radius: 20px; - padding: 5px; - padding-left: 10px; - padding-right: 10px; - color: #084cec; - background-color: white; - border-color: #084cec; - margin: 5px; -`; - -export const Question = styled(Typography)` - font-weight: bold; - font-size: 20px; -`; - -export const Answer = styled(TextField)` - margin-bottom: 1rem; - width: 100%; -`; - -export const SpaceRight = styled("span")` - margin-right: 6px; -`; diff --git a/frontend/src/components/ApplicationForm/index.tsx b/frontend/src/components/ApplicationForm/index.tsx deleted file mode 100644 index 3f57394bc..000000000 --- a/frontend/src/components/ApplicationForm/index.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { CardMedia, Container, Typography } from "@mui/material"; -import Table from "@mui/material/Table"; -import TableContainer from "@mui/material/TableContainer"; -import TableRow from "@mui/material/TableRow"; - -import { isLoggedIn } from "../../utils"; - -import { - Answer, - AuthLink, - CampaignDescription, - CampaignImageCard, - FormContainer, - FormContent, - Question, - RoleButton, - Section, - SectionHeader, - SpaceRight, - UserInfoCell, - UserInfoTypography, -} from "./applicationForm.styled"; - -import type { ChangeEvent, Dispatch, SetStateAction } from "react"; - -type Props = { - questions: { id: number; text: string; roles: Set }[]; - roles: { id: number; title: string; quantity: number }[]; - rolesSelected: number[]; - setRolesSelected: Dispatch>; - answers: { [k: string]: string }; - setAnswers: (answers: { [k: string]: string }) => void; - campaignName: string; - headerImage: string; - description: string; - userInfo: { - name: string; - zid: string; - email: string; - degree: string; - }; -}; - -const ApplicationForm = ({ - questions, - roles, - rolesSelected, - setRolesSelected, - answers, - setAnswers, - campaignName, - headerImage, - description, - userInfo, -}: Props) => { - const loggedIn = isLoggedIn(); - - // FIXME: part of CHAOS-51, handle single question related to many roles - // (currently gets printed under each rolename and all are overwritten) - const handleAnswerInput = ( - e: ChangeEvent, - qID: number - ) => { - setAnswers({ ...answers, ...{ [qID]: e.target.value } }); - }; - - const toggleRole = (id: number) => { - if (rolesSelected.includes(id)) { - setRolesSelected(rolesSelected.filter((role) => role !== id)); - } else { - setRolesSelected((existing) => [...existing, id]); - } - }; - return ( - - - - - - {description} - - - -
- Applicant Details - {loggedIn ? ( - - - - - Name: - - {userInfo.name} - - - - zID: - - {userInfo.zid} - - - - Email: - - {userInfo.email} - - - - Degree: - - {userInfo.degree} - - - -
-
- ) : ( - - Please - - login - - or - - create an account - - to apply{"" /* CHAOS-54: link to correct places! */} - - )} -
-
- Which roles are you applying for? - {roles.map((role) => ( - toggleRole(role.id)} - > - {role.title} - - ))} -
- {roles.map(({ id, title }) => ( -
- {title} - {questions.map( - (q) => - q.roles.has(id) && ( - <> - {q.text} - handleAnswerInput(e, q.id)} - /> - - ) - )} -
- ))} -
-
-
- ); -}; - -export default ApplicationForm; diff --git a/frontend/src/components/ApplicationPreviewer/applicationPreviewer.styled.ts b/frontend/src/components/ApplicationPreviewer/applicationPreviewer.styled.ts index a02b85597..907a33d16 100644 --- a/frontend/src/components/ApplicationPreviewer/applicationPreviewer.styled.ts +++ b/frontend/src/components/ApplicationPreviewer/applicationPreviewer.styled.ts @@ -1,16 +1,11 @@ -import { Typography } from "@mui/material/"; -import { styled } from "@mui/material/styles"; +import tw, { styled } from "twin.macro"; -export const Question = styled(Typography)` - font-weight: bold; -`; +import { TypographyH5 } from "components/Typography"; -export const Answer = styled(Typography)` - margin-bottom: 1rem; -`; +export const Question = tw.p`font-bold`; -export const NoAnswer = styled(Typography)` - font-style: italic; - margin-bottom: 1rem; - color: ${(props) => props.theme.palette.grey[600]}; -`; +export const Answer = tw.p`mb-4`; + +export const NoAnswer = tw.p`mb-4 italic text-gray-600`; + +export const Zid = styled(TypographyH5, tw`mb-2`); diff --git a/frontend/src/components/ApplicationPreviewer/index.tsx b/frontend/src/components/ApplicationPreviewer/index.tsx index 6a37658c8..b479764e9 100644 --- a/frontend/src/components/ApplicationPreviewer/index.tsx +++ b/frontend/src/components/ApplicationPreviewer/index.tsx @@ -1,19 +1,20 @@ -import { Container, Typography } from "@mui/material"; import { Fragment } from "react"; -import { Answer, NoAnswer, Question } from "./applicationPreviewer.styled"; +import Container from "components/Container"; + +import { Answer, NoAnswer, Question, Zid } from "./applicationPreviewer.styled"; import type { ApplicationWithQuestions } from "pages/admin/types"; +import "twin.macro"; + type Props = { application: ApplicationWithQuestions; }; const ApplicationPreviewer = ({ application }: Props) => ( - - {application.zId} - + {application.zId} {application.questions.map((question, idx) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/frontend/src/components/BackgroundWrapper/index.tsx b/frontend/src/components/BackgroundWrapper/index.tsx deleted file mode 100644 index 3753e3bd9..000000000 --- a/frontend/src/components/BackgroundWrapper/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Box, useTheme } from "@mui/system"; - -import type { PropsWithChildren } from "react"; - -const BackgroundWrapper = ({ children }: PropsWithChildren) => { - const theme = useTheme(); - return ( - - {children} - - ); -}; - -export default BackgroundWrapper; diff --git a/frontend/src/components/CampaignCard/CampaignStatus.tsx b/frontend/src/components/CampaignCard/CampaignStatus.tsx index 81c30b52c..9d0e6f3b5 100644 --- a/frontend/src/components/CampaignCard/CampaignStatus.tsx +++ b/frontend/src/components/CampaignCard/CampaignStatus.tsx @@ -8,8 +8,7 @@ const CampaignStatus = styled.button({ pending: tw`bg-[hsl(220, 60%, 90%)] shadow-[hsl(220, 60%, 90%)]! text-black`, open: tw`bg-[hsl(220, 93%, 60%)] shadow-[hsla(220, 93%, 60%, 50%)]!`, closed: tw`bg-gray-100 text-black`, - offered: tw`shadow-green-200! bg-green-200 text-green-900 hover:bg-green-300`, - rejected: tw`shadow-red-200! bg-red-200 text-red-900`, + completed: tw`shadow-orange-200! bg-orange-200 text-orange-900 hover:bg-orange-300`, }, }, diff --git a/frontend/src/components/CampaignCard/Content.tsx b/frontend/src/components/CampaignCard/Content.tsx index 8a9ff8425..58812cf59 100644 --- a/frontend/src/components/CampaignCard/Content.tsx +++ b/frontend/src/components/CampaignCard/Content.tsx @@ -164,10 +164,8 @@ const Content = ({ }; let status: VariantProps["status"]; - if (appliedFor.some(([_, status]) => status === "Success")) { - status = "offered"; - } else if (appliedFor.some(([_, status]) => status === "Rejected")) { - status = "rejected"; + if (appliedFor.some(([_, status]) => status === "Completed")) { + status = "completed"; } else if (date > endDate) { status = "closed"; } else if (appliedFor.length) { diff --git a/frontend/src/components/CampaignCard/Popup.tsx b/frontend/src/components/CampaignCard/Popup.tsx index 896e59aa8..ebef028d5 100644 --- a/frontend/src/components/CampaignCard/Popup.tsx +++ b/frontend/src/components/CampaignCard/Popup.tsx @@ -42,9 +42,9 @@ const Popup = ({ appliedFor, positions, open, closeModal }: Props) => { description="Roles available for this campaign" open={open} closeModal={closeModal} - > -
    - {positions.map((pos) => ( + > +
      + {positions.map((pos) => ( {pos.name} diff --git a/frontend/src/components/CampaignCard/index.tsx b/frontend/src/components/CampaignCard/index.tsx index f22e38e76..663b9fa65 100644 --- a/frontend/src/components/CampaignCard/index.tsx +++ b/frontend/src/components/CampaignCard/index.tsx @@ -58,7 +58,7 @@ const CampaignCard = ({ const content = ( ({ - display: "flex", - padding: "16px 9px", -})); +export const FormContainer = tw.div`flex flex-row p-4 px-[9px]`; -export const ImageUploadWrapper = styled("label")(() => ({ - display: "block", - minWidth: "60px", - height: "60px", - lineHeight: "60px", - margin: "0px", -})); +export const ImageUploadWrapper = tw.label`m-0 block h-[60px] min-w-[60px] leading-[60px]`; -export const TextInput = styled("input")(() => ({ - height: "30px", - width: "133px", - margin: "15px", - borderRadius: "12px", - borderColor: "black", - borderWidth: "1px", - padding: "10px", -})); +export const TextInput = tw.input`m-4 h-8 w-[133px] rounded-xl border border-black p-3`; -export const UploadButton = styled("button")(() => ({ - height: "30px", - width: "30px", - marginTop: "15px", - borderRadius: "12px", - borderColor: "black", - borderWidth: "1px", -})); +export const UploadButton = tw.button`mt-4 h-8 w-8 rounded-[12px] border border-black`; diff --git a/frontend/src/components/Dropdown/DropdownOption.tsx b/frontend/src/components/Dropdown/DropdownOption.tsx index c88c14b99..694813018 100644 --- a/frontend/src/components/Dropdown/DropdownOption.tsx +++ b/frontend/src/components/Dropdown/DropdownOption.tsx @@ -1,5 +1,5 @@ import { Menu } from "@headlessui/react"; -import tw from "twin.macro"; +import "twin.macro"; import type { ComponentProps, MouseEvent, ReactElement } from "react"; diff --git a/frontend/src/components/Dropdown/index.tsx b/frontend/src/components/Dropdown/index.tsx index c5d77a408..b603c1f09 100644 --- a/frontend/src/components/Dropdown/index.tsx +++ b/frontend/src/components/Dropdown/index.tsx @@ -1,6 +1,6 @@ import { Menu } from "@headlessui/react"; import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; -import tw from "twin.macro"; +import "twin.macro"; import Card from "components/Card"; diff --git a/frontend/src/components/InputPopup/index.tsx b/frontend/src/components/InputPopup/index.tsx index cf90e98c9..7837a33a7 100644 --- a/frontend/src/components/InputPopup/index.tsx +++ b/frontend/src/components/InputPopup/index.tsx @@ -1,6 +1,12 @@ -import { Box, Button, Popover, TextField, Typography } from "@mui/material"; +import { Popover } from "@mui/material"; import { useState } from "react"; +import Button from "components/Button"; +import Input from "components/Input"; +import { TypographyH6 } from "components/Typography"; + +import "twin.macro"; + import type { ChangeEvent, ChangeEventHandler, @@ -50,7 +56,6 @@ const InputPopup = ({ }); }; const handleInputChange = (e: ChangeEvent) => { - // eslint-disable-next-line no-shadow const { name, value } = e.target; setFormValue(name, value); }; @@ -68,32 +73,32 @@ const InputPopup = ({ horizontal: "right", }} > - - {title} +
      + {title}
      { e.preventDefault(); onSubmit(formValues); }} > - - - +
      +
      + + {label} + + - +
      {typeof children === "function" ? children({ formValues, setFormValue, handleInputChange }) : children} - +
      -
      +
      ); }; diff --git a/frontend/src/components/NavBar/index.tsx b/frontend/src/components/NavBar/index.tsx index 4e0c150ef..836ec0451 100644 --- a/frontend/src/components/NavBar/index.tsx +++ b/frontend/src/components/NavBar/index.tsx @@ -1,5 +1,5 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { NavLink, Link as RouterLink } from "react-router-dom"; import tw, { styled } from "twin.macro"; @@ -22,9 +22,17 @@ const NavButton = styled(NavLink, { }); const NavBar = ({ campaign }: { campaign: string }) => { - const loggedIn = isLoggedIn(); + const [loggedIn, setLoggedIn] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + useEffect(() => { + async function checkLoginStatus() { + const status = await isLoggedIn(); + setLoggedIn(status); + } + checkLoginStatus(); + }, []); + return (
      @@ -57,15 +65,15 @@ const NavBar = ({ campaign }: { campaign: string }) => { {loggedIn ? ( ) : ( - // - // Get Started - // -

      - Coming Soon! -

      + + Get Started + + //

      + // Coming Soon! + //

      )} diff --git a/frontend/src/components/PrivateRoute/index.tsx b/frontend/src/components/PrivateRoute/index.tsx index a818b9f24..fc7dfc9bc 100644 --- a/frontend/src/components/PrivateRoute/index.tsx +++ b/frontend/src/components/PrivateRoute/index.tsx @@ -12,8 +12,9 @@ const PrivateRoute = (props: ComponentProps) => { const navigate = useNavigate(); useEffect(() => { - function getLoggedIn() { - if (!isLoggedIn()) { + async function getLoggedIn() { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { navigate("/"); } setLoading(false); diff --git a/frontend/src/components/QuestionComponents/Dropdown/index.tsx b/frontend/src/components/QuestionComponents/Dropdown/index.tsx new file mode 100644 index 000000000..556b8a968 --- /dev/null +++ b/frontend/src/components/QuestionComponents/Dropdown/index.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import tw from 'twin.macro'; + +interface DropdownOption { + id: string | number; + label: string; +} + +interface DropdownProps { + id: number; + question: string; + description?: string; + options: DropdownOption[]; + required?: boolean; + defaultValue?: string | number; + onChange?: (value: string | number) => void; + onSubmit?: (questionId: number, value: string | number) => void; + disabled?: boolean; + placeholder?: string; + width?: string; + height?: string; +} + +const Dropdown: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue, + onChange, + onSubmit, + disabled = false, + width = "max-w-4xl", + height = "", + placeholder = "Select an option", +}) => { + const [value, setValue] = useState(defaultValue); + + const handleSelect = (selectedValue: string) => { + // Convert back to number if the original option id was a number + const originalOption = options.find(opt => opt.id.toString() === selectedValue); + const optionId = originalOption ? originalOption.id : selectedValue; + + setValue(optionId); + + if (onChange) onChange(optionId); + if (onSubmit) onSubmit(id, optionId); + }; + + return ( +
      +
      + + {required && *} +
      + + {description && ( +

      {description}

      + )} + + +
      + ); +}; + +export default Dropdown; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/MultiChoice/index.tsx b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx new file mode 100644 index 000000000..add1317db --- /dev/null +++ b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; + +interface Option { + id: string | number; + label: string; +} + +interface MultiChoiceProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: string | number; + onChange?: (value: string | number) => void; + onSubmit?: (questionId: number, value: string | number) => void; + disabled?: boolean; +} + +const MultiChoice: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue, + onChange, + onSubmit, + disabled = false, +}) => { + const [selectedOption, setSelectedOption] = useState(defaultValue); + + const handleChange = (value: string) => { + const optionId = isNaN(Number(value)) ? value : Number(value); + setSelectedOption(optionId); + + if (onChange) onChange(optionId); + if (onSubmit) onSubmit(id, optionId); + }; + + return ( +
      +
      + + {required && *} +
      + + {description && ( +

      {description}

      + )} + + + {options.map((option) => ( +
      + + +
      + ))} +
      +
      + ); +}; + +export default MultiChoice; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/MultiSelect/index.tsx b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx new file mode 100644 index 000000000..50b6b88eb --- /dev/null +++ b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface Option { + id: string | number; + label: string; +} + +interface MultiSelectProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: Array; + onChange?: (value: Array) => void; + onSubmit?: (questionId: number, value: Array) => void; + disabled?: boolean; +} + +const MultiSelect: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue = [], + onChange, + onSubmit, + disabled = false, +}) => { + const [selectedOptions, setSelectedOptions] = useState>(defaultValue); + + const handleChange = (optionId: string | number, checked: boolean) => { + let newSelectedOptions: Array = []; + + if (checked) { + newSelectedOptions = [...selectedOptions, optionId]; + } else { + newSelectedOptions = selectedOptions.filter(id => id !== optionId); + } + + setSelectedOptions(newSelectedOptions); + + if (onChange) onChange(newSelectedOptions); + if (onSubmit) onSubmit(id, newSelectedOptions); + }; + + return ( +
      +
      + + {required && *} +
      + + {description && ( +

      {description}

      + )} + +
      + {options.map((option) => ( +
      + handleChange(option.id, !!checked)} + disabled={disabled} + /> + +
      + ))} +
      +
      + ); +}; + +export default MultiSelect; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/Ranking/index.tsx b/frontend/src/components/QuestionComponents/Ranking/index.tsx new file mode 100644 index 000000000..ca05f05e4 --- /dev/null +++ b/frontend/src/components/QuestionComponents/Ranking/index.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import tw, {css} from 'twin.macro'; +import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; + +interface Option { + id: string | number; + label: string; +} + +interface RankedOption extends Option { + rank: number; +} + +interface RankingProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: Array; + onChange?: (value: Array) => void; + onSubmit?: (questionId: number, value: Array) => void; + disabled?: boolean; + width?: string; + height?: string; +} + +const Ranking: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue = [], + onChange, + onSubmit, + disabled = false, + width = "max-w-3xl", + height = "", +}) => { + const [rankedOptions, setRankedOptions] = useState([]); + + // Initialize ranked options on mount or when options change + useEffect(() => { + const safeDefaultValue = Array.isArray(defaultValue) ? defaultValue : []; + if (safeDefaultValue.length > 0) { + const initialRanked = safeDefaultValue.map((optionId, index) => { + const option = options.find(opt => opt.id === optionId); + return option ? { ...option, rank: index + 1 } : null; + }).filter(Boolean) as RankedOption[]; + + const rankedIds = initialRanked.map(o => o.id); + const unrankedOptions = options + .filter(opt => !rankedIds.includes(opt.id)) + .map((opt, i) => ({ ...opt, rank: initialRanked.length + i + 1 })); + + setRankedOptions([...initialRanked, ...unrankedOptions]); + } else { + setRankedOptions(options.map((opt, i) => ({ ...opt, rank: i + 1 }))); + } + }, [options, defaultValue]); + + const handleDragEnd = (result: any) => { + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + const newRankedOptions = Array.from(rankedOptions); + const [removed] = newRankedOptions.splice(result.source.index, 1); + newRankedOptions.splice(result.destination.index, 0, removed); + + const updatedOptions = newRankedOptions.map((opt, idx) => ({ + ...opt, + rank: idx + 1 + })); + + setRankedOptions(updatedOptions); + + const orderedIds = updatedOptions.map(opt => opt.id); + if (onChange) onChange(orderedIds); + if (onSubmit) onSubmit(id, orderedIds); + }; + + const sortedOptions = [...rankedOptions].sort((a, b) => a.rank - b.rank); + + return ( +
      +
      + + {required && *} +
      + + {description && ( +

      {description}

      + )} + + + + {(provided, snapshot) => ( +
      + {sortedOptions.map((option, index) => ( + + {(provided, snapshot) => ( +
      + + {option.rank} + + {option.label} +
      + )} +
      + ))} + {provided.placeholder} +
      + )} +
      +
      +
      + ); +}; + +export default Ranking; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx new file mode 100644 index 000000000..0ea8679fb --- /dev/null +++ b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx @@ -0,0 +1,73 @@ +import React, { useState, ChangeEvent } from 'react'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import tw from 'twin.macro'; + +interface ShortAnswerProps { + id: number; + question: string; + description?: string; + required?: boolean; + defaultValue?: string; + onChange?: (value: string) => void; + onSubmit?: (questionId: number, value: string) => void; + disabled?: boolean; + rows?: number; + placeholder?: string; + width?: string; + height?: string; +} + +const ShortAnswer: React.FC = ({ + id, + question, + description, + required = false, + defaultValue = '', + onChange, + onSubmit, + disabled = false, + rows = 3, + placeholder = "Your answer", + width = "max-w-4xl", + height = "", +}) => { + const [value, setValue] = useState(defaultValue); + + const handleChange = (e: ChangeEvent) => { + setValue(e.target.value); + if (onChange) onChange(e.target.value); + }; + + const handleBlur = () => { + if (onSubmit && value.trim() !== '') { + onSubmit(id, value); + } + }; + + return ( +
      +
      + + {required && *} +
      + + {description && ( +

      {description}

      + )} + +