diff --git a/.env.example b/.env.example index 1dce100..d317774 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,30 @@ +# RUST_ENV : Mode environment aplikasi (misal: development, production) +# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error) +# PORT : Port yang digunakan backend service +# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket) +# SURREALDB_USERNAME : Username untuk SurrealDB +# SURREALDB_PASSWORD : Password untuk SurrealDB +# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan +# SURREALDB_DBNAME : Nama database SurrealDB +# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia) +# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia) +# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP) +# SMTP_PASSWORD : Password email SMTP +# SMTP_NAME : Nama pengirim email (misal: nama aplikasi) +# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com) +# REDISDB_URL : URL/host Redis database +# FE_URL : URL frontend aplikasi (misal: http://localhost) +# MINIO_ENDPOINT : Endpoint MinIO object storage +# MINIO_BUCKET_NAME : Nama bucket default MinIO +# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER +# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD +# MINIO_ROOT_USER : Username root MinIO (default: minioadmin) +# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin) +# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google) +# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google + RUST_ENV=development +RUST_LOG=debug PORT=4099 SURREALDB_URL=ws://localhost:8000/rpc SURREALDB_USERNAME=root @@ -15,10 +41,9 @@ REDISDB_URL=localhost FE_URL=http://localhost MINIO_ENDPOINT=http://localhost:9000 MINIO_BUCKET_NAME=default_bucket -MINIO_ACCESS_KEY=your-minio-access-key -MINIO_SECRET_KEY=your-minio-secret-key -MAIL_USER=your-email@example.com -MAIL_PASSWORD=your-smtp-password -MAIL_HOST=smtp.gmail.com -MAIL_PORT=465 -MAIL_SECURE=true +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_SECURE=false + +GOOGLE_CLIENT_ID="your_google_client_id" +GOOGLE_CLIENT_SECRET="your_google_client_secret" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0e56b5e..8dd9b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,9 +117,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -147,22 +147,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -173,9 +173,9 @@ checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64" [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "approx" @@ -197,9 +197,9 @@ dependencies = [ [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] @@ -316,7 +316,7 @@ dependencies = [ "proc-macro2", "quote", "strum 0.26.3", - "syn 2.0.104", + "syn 2.0.106", "thiserror 1.0.69", ] @@ -363,7 +363,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -374,13 +374,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -394,6 +394,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auto-future" version = "1.0.0" @@ -461,6 +467,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-test" version = "17.3.0" @@ -640,7 +669,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -673,9 +702,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" @@ -709,9 +738,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ "shlex", ] @@ -973,7 +1002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -997,7 +1026,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1008,7 +1037,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1042,13 +1071,13 @@ dependencies = [ [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1103,7 +1132,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1128,6 +1157,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dtoa" version = "1.0.10" @@ -1145,9 +1180,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "earcutr" @@ -1246,9 +1281,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1343,6 +1378,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fst" version = "0.4.7" @@ -1415,9 +1456,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -1434,7 +1475,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1512,9 +1553,9 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ddb1950450d67efee2bbc5e429c68d052a822de3aad010d28b351fbb705224" +checksum = "75a4dcd69d35b2c87a7c83bce9af69fd65c9d68d3833a0ded568983928f3fc99" dependencies = [ "approx 0.5.1", "num-traits", @@ -1564,6 +1605,25 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.10.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.6.0" @@ -1604,15 +1664,39 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heapless" version = "0.8.0" @@ -1733,6 +1817,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http", "http-body", "httparse", @@ -1762,10 +1847,26 @@ dependencies = [ ] [[package]] -name = "hyper-util" -version = "0.1.15" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -1779,10 +1880,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1797,7 +1900,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -1940,13 +2043,13 @@ dependencies = [ "imphnen-libs", "imphnen-utils", "lazy_static", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "serde_json", "surrealdb", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "tower-http", "tracing", "tracing-subscriber", @@ -1971,7 +2074,7 @@ dependencies = [ "imphnen-utils", "lazy_static", "log", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "serde_json", @@ -1999,7 +2102,7 @@ dependencies = [ "imphnen-libs", "imphnen-utils", "lazy_static", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "serde_json", @@ -2016,10 +2119,11 @@ dependencies = [ name = "imphnen-entities" version = "0.1.0" dependencies = [ + "anyhow", "axum", "serde", "surrealdb", - "thiserror 2.0.12", + "thiserror 2.0.14", "utoipa", ] @@ -2037,7 +2141,7 @@ dependencies = [ "imphnen-utils", "lazy_static", "log", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", "regex", "serde", @@ -2068,7 +2172,7 @@ dependencies = [ "imphnen-middleware", "imphnen-utils", "lazy_static", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "serde_json", @@ -2086,6 +2190,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", "axum-test", "chrono", "dotenvy", @@ -2095,15 +2200,19 @@ dependencies = [ "imphnen-utils", "lazy_static", "log", + "mockall", + "oauth2", "once_cell", - "rand 0.9.1", + "rand 0.9.2", "regex", + "reqwest", "serde", "serde_json", - "strum 0.27.1", - "strum_macros 0.27.1", + "strum 0.27.2", + "strum_macros 0.27.2", "surrealdb", "tokio", + "tokio-test", "tower-http", "tracing", "utoipa", @@ -2116,17 +2225,26 @@ dependencies = [ name = "imphnen-libs" version = "0.1.0" dependencies = [ + "anyhow", "argon2", "axum", + "base64 0.22.1", "chrono", "dotenvy", + "hex", + "hmac", "imphnen-entities", "jsonwebtoken", "lettre", "log", + "once_cell", + "reqwest", "serde", + "sha2", "surrealdb", "tokio", + "urlencoding", + "uuid", ] [[package]] @@ -2135,6 +2253,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", "axum-test", "chrono", "futures", @@ -2143,7 +2262,7 @@ dependencies = [ "imphnen-libs", "imphnen-utils", "lazy_static", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "serde_json", @@ -2163,16 +2282,21 @@ dependencies = [ "anyhow", "axum", "axum-test", + "base64 0.22.1", "chrono", + "dotenvy", "imphnen-entities", "imphnen-libs", - "rand 0.9.1", + "rand 0.9.2", + "reqwest", "serde", "serde_json", - "strum 0.27.1", - "strum_macros 0.27.1", + "sha2", + "strum 0.27.2", + "strum_macros 0.27.2", "surrealdb", "tracing", + "tracing-subscriber", "uuid", "validator", ] @@ -2195,7 +2319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2210,9 +2334,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ "bitflags", "cfg-if", @@ -2295,7 +2419,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2337,7 +2461,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax", + "regex-syntax 0.8.5", "string_cache", "term", "tiny-keccak", @@ -2351,7 +2475,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata", + "regex-automata 0.4.9", ] [[package]] @@ -2362,9 +2486,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lettre" -version = "0.11.17" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2a0354e9ece2fcdcf9fa53417f6de587230c0c248068eb058fa26c4a753179" +checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" dependencies = [ "async-trait", "base64 0.22.1", @@ -2382,7 +2506,7 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", - "socket2", + "socket2 0.6.0", "tokio", "tokio-native-tls", "url", @@ -2399,9 +2523,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libm" @@ -2411,9 +2535,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags", "libc", @@ -2474,7 +2598,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -2514,7 +2638,16 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", ] [[package]] @@ -2569,7 +2702,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2608,6 +2741,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "multer" version = "3.1.0" @@ -2786,6 +2945,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.21.7", + "chrono", + "getrandom 0.2.16", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -2810,7 +2989,7 @@ dependencies = [ "itertools 0.14.0", "parking_lot", "percent-encoding", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tracing", "url", @@ -2854,7 +3033,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2968,7 +3147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.14", "ucd-trie", ] @@ -3032,7 +3211,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicase", ] @@ -3115,6 +3294,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3153,14 +3358,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -3214,13 +3419,13 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.14" +version = "0.6.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b450dad8382b1b95061d5ca1eb792081fb082adf48c678791fe917509596d5f" +checksum = "9ad6644cb07b7f3488b9f3d2fde3b4c0a7fa367cafefb39dff93a659f76eb786" dependencies = [ "ahash 0.8.12", "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "parking_lot", ] @@ -3237,8 +3442,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", - "thiserror 2.0.12", + "socket2 0.5.10", + "thiserror 2.0.14", "tokio", "tracing", "web-time", @@ -3253,13 +3458,13 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand 0.9.1", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.14", "tinyvec", "tracing", "web-time", @@ -3274,7 +3479,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -3330,9 +3535,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -3383,7 +3588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.1", + "rand 0.9.2", ] [[package]] @@ -3394,9 +3599,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -3404,9 +3609,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -3420,9 +3625,9 @@ checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c" [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] @@ -3455,7 +3660,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3466,8 +3671,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -3478,9 +3692,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -3498,23 +3718,28 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3525,6 +3750,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3544,7 +3770,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -3579,7 +3805,7 @@ checksum = "5f0ec466e5d8dca9965eb6871879677bef5590cf7525ad96cae14376efb75073" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3590,7 +3816,7 @@ checksum = "d3415e1bc838c36f9a0a2ac60c0fa0851c72297685e66592c44870d82834dfa2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3716,7 +3942,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn 2.0.106", "walkdir", ] @@ -3741,8 +3967,8 @@ dependencies = [ "futures-util", "http", "mime", - "rand 0.9.1", - "thiserror 2.0.12", + "rand 0.9.2", + "thiserror 2.0.14", ] [[package]] @@ -3773,9 +3999,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -3816,9 +4042,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "log", "once_cell", @@ -3852,9 +4078,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -4001,14 +4227,14 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "indexmap 2.10.0", "itoa", @@ -4068,7 +4294,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4110,9 +4336,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4137,7 +4363,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.14", "time", ] @@ -4149,9 +4375,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -4184,13 +4410,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spade" version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a14e31a007e9f85c32784b04f89e6e194bb252a4d41b4a8ccd9e77245d901c8c" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", "num-traits", "robust", "smallvec", @@ -4281,11 +4517,11 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -4298,20 +4534,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4351,7 +4586,7 @@ dependencies = [ "surrealdb-core", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.23.1", "tokio-util", "tracing", "trice", @@ -4468,7 +4703,7 @@ dependencies = [ "getrandom 0.2.16", "lru", "parking_lot", - "quick_cache 0.6.14", + "quick_cache 0.6.16", "revision 0.10.0", "vart 0.9.2", ] @@ -4486,9 +4721,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4512,7 +4747,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4529,6 +4764,27 @@ dependencies = [ "windows", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -4570,11 +4826,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "tests" version = "0.1.0" dependencies = [ - "async-trait", "axum", "chrono", "dotenvy", @@ -4586,10 +4847,10 @@ dependencies = [ "imphnen-iam", "imphnen-libs", "imphnen-utils", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_json", - "strum 0.27.1", + "strum 0.27.2", "surrealdb", "tokio", "tower", @@ -4608,11 +4869,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.14", ] [[package]] @@ -4623,18 +4884,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4713,9 +4974,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -4726,9 +4987,9 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4739,7 +5000,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4762,6 +5023,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.23.1" @@ -4774,15 +5059,27 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.23.0", "webpki-roots 0.26.11", ] [[package]] -name = "tokio-util" -version = "0.7.15" +name = "tokio-tungstenite" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -4876,7 +5173,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4906,10 +5203,14 @@ version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -4952,6 +5253,23 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.14", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" @@ -4970,7 +5288,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.1", + "rand 0.9.2", "serde", "web-time", ] @@ -5039,6 +5357,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -5086,7 +5405,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5109,13 +5428,13 @@ dependencies = [ [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", - "rand 0.9.1", + "rand 0.9.2", "serde", "wasm-bindgen", ] @@ -5147,7 +5466,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5236,7 +5555,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -5271,7 +5590,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5398,7 +5717,7 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ - "windows-core 0.57.0", + "windows-core", "windows-targets 0.52.6", ] @@ -5408,25 +5727,12 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", + "windows-implement", + "windows-interface", "windows-result 0.1.2", "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link", - "windows-result 0.3.4", - "windows-strings", -] - [[package]] name = "windows-implement" version = "0.57.0" @@ -5435,18 +5741,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5457,18 +5752,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5477,6 +5761,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result 0.3.4", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5528,7 +5823,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -5549,10 +5844,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5696,7 +5992,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror 2.0.12", + "thiserror 2.0.14", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5737,7 +6033,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -5758,7 +6054,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5778,7 +6074,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -5801,9 +6097,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -5818,7 +6114,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8507b4d..773c28e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,47 +16,58 @@ members = [ [workspace.dependencies] +oauth2 = "5.0.0" +reqwest = { version = "0.12.23", features = ["json"] } +serde_json = "1.0.142" axum = { version = "0.8.4", features = ["multipart"] } -log = "0.4.25" -serde = { version = "1.0.217", features = ["derive"] } -serde_json = "1.0.138" -tokio = { version = "1.45.0", features = ["full"] } +log = "0.4.27" +serde = { version = "1.0.219", features = ["derive"] } +tokio = { version = "1.47.1", features = ["full"] } argon2 = { version = "0.5.3", features = ["password-hash"] } jsonwebtoken = "9.3.1" chrono = "0.4.41" -utoipa = { version = "5.3.1", features = ["axum_extras"] } -utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] } -lettre = { version = "0.11.16", features = ["tokio1-native-tls"] } +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } +lettre = { version = "0.11.18", features = ["tokio1-native-tls"] } surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] } -thiserror = "2.0.12" -anyhow = "1.0.98" -rand = { version = "0.9.1", features = ["std", "alloc"] } +thiserror = "2.0.14" +anyhow = "1.0.99" +rand = { version = "0.9.2", features = ["std", "alloc"] } rand_distr = "0.5.1" -tower-http = { version = "0.6.4", features = ["cors", "trace"] } -http-body-util = "0.1.1" - +tower-http = { version = "0.6.6", features = ["cors", "trace"] } +http-body-util = "0.1.3" validator = { version = "0.20.0", features = ["derive"] } -lazy_static = "1.4.0" +lazy_static = "1.5.0" regex = "1.11.1" -axum-test = "17.2.0" -fancy-regex = "0.15.0" +axum-test = "17.3.0" +axum-extra = { version = "0.10.1", features = ["typed-header"] } +fancy-regex = "0.16.1" futures = "0.3.31" tower = "0.5.2" env_logger = "0.11.8" -dotenvy = "0.15.7" -tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["fmt"] } -once_cell = "1.21.3" -uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] } -strum = { version = "0.27.1", features = ["derive"] } -strum_macros = "0.27.1" +tracing = "0.1.41" +uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] } +strum = { version = "0.27.2", features = ["derive"] } +strum_macros = "0.27.2" +base64 = "0.22.1" +sha2 = "0.10.9" +hmac = "0.12" +hex = "0.4" +urlencoding = "2.1" hyper = "1.6.0" -hyper-util = "0.1.0" -async-trait = "0.1.75" -tokio-tungstenite = "0.23" +hyper-util = "0.1.16" +minio = "0.3.0" + + +tokio-test = "0.4.4" +mockall = "0.13.1" +once_cell = "1.21.3" +dotenvy = "0.15.7" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tokio-tungstenite = "0.27" url = "2.5" futures-util = "0.3" - +http = "1.3" tests = { path = "./tests" } imphnen-iam = { path = "./imphnen-iam" } imphnen-cms = { path = "./imphnen-cms" } diff --git a/deploy-test.sh b/deploy-test.sh new file mode 100644 index 0000000..2977d65 --- /dev/null +++ b/deploy-test.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Script deploy ke VPS + +set -e + +REMOTE_USER="asephs" +REMOTE_HOST="70.153.9.42" +REMOTE_PATH="/home/asephs/imphnen-backend-service" + +# Build project +taskset -c 0,1 cargo build --release -j 2 + +# Rsync hasil build dan file yang diperlukan +rsync -avz --delete \ + target/release/ \ + $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/target/release/ + +# Sync file konfigurasi dan source code (jika perlu) +rsync -avz --delete \ + imphnen-backend/ \ + $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/imphnen-backend/ + +rsync -avz --delete \ + docker-compose.yml Dockerfile \ + $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/ + +# Tambahkan file lain jika diperlukan +ssh $REMOTE_USER@$REMOTE_HOST << 'EOF' +cd /home/asephs/imphnen-backend-service +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin" +export PATH=$PATH:/root/.local/share/pnpm +export PATH=$PATH:/home/asephs/.nvm/versions/node/v22.17.1/bin +export PATH=$PATH:/home/asephs/.bun/bin/bun + +if [ -f ~/.bashrc ]; then + source ~/.bashrc +fi +pm2 restart 4 --update-env +EOF + +echo "Deploy selesai ke $REMOTE_HOST:$REMOTE_PATH" diff --git a/docker-compose.yml b/docker-compose.yml index a13a103..1588455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,3 +14,19 @@ services: command: start --log trace --user root --pass root ports: - "8000:8000" + + minio: + image: minio/minio:latest + container_name: minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + +volumes: + minio_data: diff --git a/imphnen-backend/Cargo.toml b/imphnen-backend/Cargo.toml index aa97468..283f7dd 100644 --- a/imphnen-backend/Cargo.toml +++ b/imphnen-backend/Cargo.toml @@ -3,6 +3,46 @@ name = "imphnen-backend" version = "0.1.0" edition = "2021" +[[bin]] +name = "api" +path = "src/main.rs" + +[[bin]] +name = "clear_db_test" +path = "src/bin/clear_db_test.rs" + +[[bin]] +name = "seeder" +path = "src/bin/seeder.rs" + +[[bin]] +name = "seed_events" +path = "src/bin/seed_events.rs" + +[[bin]] +name = "seed_gacha_rolls" +path = "src/bin/seed_gacha_rolls.rs" + +[[bin]] +name = "seed_mentor_user" +path = "src/bin/seed_mentor_user.rs" + +[[bin]] +name = "seed_permissions" +path = "src/bin/seed_permissions.rs" + +[[bin]] +name = "seed_roles" +path = "src/bin/seed_roles.rs" + +[[bin]] +name = "seed_roles_permissions" +path = "src/bin/seed_roles_permissions.rs" + +[[bin]] +name = "seed_users" +path = "src/bin/seed_users.rs" + [dependencies] imphnen-libs.workspace = true imphnen-utils.workspace = true diff --git a/imphnen-backend/src/bin/api.rs b/imphnen-backend/src/bin/api.rs index 1e0f00d..5f446e9 100644 --- a/imphnen-backend/src/bin/api.rs +++ b/imphnen-backend/src/bin/api.rs @@ -1,3 +1,4 @@ +use axum::Router; use imphnen_gateway::gateway_service; use imphnen_libs::axum_init; @@ -5,7 +6,10 @@ use imphnen_libs::axum_init; async fn main() { env_logger::init(); axum_init(|surrealdb_ws, surrealdb_mem| async { - gateway_service(surrealdb_ws, surrealdb_mem).await + let app = gateway_service(surrealdb_ws, surrealdb_mem).await; + let mut router = Router::new(); + router = router.nest("/api/v1/auth", imphnen_iam::v1::auth::auth_router()); + app.merge(router) }) .await; } diff --git a/imphnen-backend/src/bin/clear_db_test.rs b/imphnen-backend/src/bin/clear_db_test.rs index 6fe825c..1cdde6b 100644 --- a/imphnen-backend/src/bin/clear_db_test.rs +++ b/imphnen-backend/src/bin/clear_db_test.rs @@ -30,7 +30,7 @@ async fn main() -> Result<(), Box> { let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis - let (ws_stream, _) = connect_async(url).await?; + let (ws_stream, _) = connect_async(url.as_str()).await?; let (mut write, mut read) = ws_stream.split(); // Authenticate (signin) @@ -43,7 +43,7 @@ async fn main() -> Result<(), Box> { "id": 1 }).to_string(); println!("DEBUG: Sending signin query: {}", signin_query); - write.send(Message::Text(signin_query)).await?; + write.send(Message::Text(signin_query.into())).await?; let signin_response = read.next().await.ok_or("Failed to read signin response")?; let signin_response_msg = signin_response?; @@ -60,7 +60,7 @@ async fn main() -> Result<(), Box> { "id": 2 }).to_string(); println!("DEBUG: Sending use query: {}", use_query); - write.send(Message::Text(use_query)).await?; + write.send(Message::Text(use_query.into())).await?; let use_response = read.next().await.ok_or("Failed to read use response")?; let use_response_msg = use_response?; @@ -82,7 +82,7 @@ async fn main() -> Result<(), Box> { }).to_string(); println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json); - write.send(Message::Text(query_json)).await?; + write.send(Message::Text(query_json.into())).await?; let response_result = read.next().await.ok_or("Stream ended unexpectedly")?; @@ -100,7 +100,7 @@ async fn main() -> Result<(), Box> { }).to_string(); println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json); - write.send(Message::Text(delete_all_json)).await?; + write.send(Message::Text(delete_all_json.into())).await?; let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?; match delete_response_result { @@ -137,7 +137,7 @@ async fn main() -> Result<(), Box> { "params": [select_query], "id": i + 1000 }).to_string(); - write.send(Message::Text(select_json)).await?; + write.send(Message::Text(select_json.into())).await?; let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?; match select_response_result { Ok(select_msg) => { diff --git a/imphnen-backend/src/bin/seed_events.rs b/imphnen-backend/src/bin/seed_events.rs index ef2667a..fec6e68 100644 --- a/imphnen-backend/src/bin/seed_events.rs +++ b/imphnen-backend/src/bin/seed_events.rs @@ -1,21 +1,20 @@ use imphnen_cms::v1::landing::events::events_schema::EventsSchema; -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, Env}; +use imphnen_utils::{get_iso_date}; use std::error::Error; use surrealdb::engine::any; use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid + #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { username: &env.surrealdb_username, password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; let events = vec![ diff --git a/imphnen-backend/src/bin/seed_gacha_rolls.rs b/imphnen-backend/src/bin/seed_gacha_rolls.rs index 839ddb6..ef78b53 100644 --- a/imphnen-backend/src/bin/seed_gacha_rolls.rs +++ b/imphnen-backend/src/bin/seed_gacha_rolls.rs @@ -1,14 +1,11 @@ -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, Env}; - +use imphnen_utils::{get_iso_date}; use std::error::Error; use surrealdb::opt::auth::Root; use surrealdb::sql::Thing; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; use surrealdb::engine::any; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { @@ -16,8 +13,8 @@ async fn main() -> Result<(), Box> { password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; db.query("DELETE type::thing('app_gacha_items', $id)") diff --git a/imphnen-backend/src/bin/seed_mentor_user.rs b/imphnen-backend/src/bin/seed_mentor_user.rs index 63cea4d..c5940a8 100644 --- a/imphnen-backend/src/bin/seed_mentor_user.rs +++ b/imphnen-backend/src/bin/seed_mentor_user.rs @@ -1,13 +1,11 @@ -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, hash_password, Env}; +use imphnen_utils::{get_iso_date, hash_password}; use serde_json::json; use std::error::Error; use surrealdb::opt::auth::Root; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; use surrealdb::engine::any; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { @@ -15,8 +13,8 @@ async fn main() -> Result<(), Box> { password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; db.query("DELETE type::thing('app_mentors', $id)") @@ -28,7 +26,7 @@ async fn main() -> Result<(), Box> { .await?; use surrealdb::sql::Thing; - db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, created_at = $created_at, updated_at = $updated_at") + db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, legal_name = $legal_name, domicile = $domicile, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, last_education = $last_education, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, portfolio_url = $portfolio_url, created_at = $created_at, updated_at = $updated_at") .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("fullname", "Mentor User")) .bind(("email", "mentor@example.com")) @@ -41,20 +39,23 @@ async fn main() -> Result<(), Box> { .bind(("gender", "male")) .bind(("birthdate", "1990-05-15")) .bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a")))) + .bind(("legal_name", "Mentor User")) + .bind(("domicile", "Jakarta, Indonesia")) + // .bind(("identity_document_url", "https://example.com/ktp.jpg")) + .bind(("phone_for_verification", "081234567890")) + .bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.")) + .bind(("last_education", "S1 Teknik Informatika")) + .bind(("linkedin_url", "https://linkedin.com/in/mentor")) + .bind(("github_url", "https://github.com/mentor")) + .bind(("cv_url", Option::::None)) + .bind(("portfolio_url", Option::::None)) .bind(("created_at", get_iso_date())) .bind(("updated_at", get_iso_date())) .await?; - db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, legal_name = $legal_name, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at, email = $email") + db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at") .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901")))) - .bind(("legal_name", "Mentor User")) - .bind(("identity_document_url", "https://example.com/ktp.jpg")) - .bind(("phone_for_verification", "081234567890")) - .bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.")) - .bind(("linkedin_url", "https://linkedin.com/in/mentor")) - .bind(("github_url", "https://github.com/mentor")) - .bind(("cv_url", Option::::None)) .bind(("industries", vec!["Software", "Education"])) .bind(("expertise", vec!["Rust", "Microservices"])) .bind(("languages", vec!["Indonesian", "English"])) @@ -74,7 +75,6 @@ async fn main() -> Result<(), Box> { .bind(("is_deleted", false)) .bind(("created_at", get_iso_date())) .bind(("updated_at", get_iso_date())) - .bind(("email", "mentor@example.com")) .await?; println!("Mentor created successfully!"); println!("Updating user with mentor_id..."); diff --git a/imphnen-backend/src/bin/seed_permissions.rs b/imphnen-backend/src/bin/seed_permissions.rs index 2859662..24958f2 100644 --- a/imphnen-backend/src/bin/seed_permissions.rs +++ b/imphnen-backend/src/bin/seed_permissions.rs @@ -1,6 +1,5 @@ use imphnen_iam::PermissionsEnum; -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, Env}; +use imphnen_utils::{get_iso_date}; use serde_json::json; use std::error::Error; use surrealdb::engine::any; @@ -8,17 +7,15 @@ use surrealdb::opt::auth::Root; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { username: &env.surrealdb_username, password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; for permission in [ @@ -54,6 +51,7 @@ async fn main() -> Result<(), Box> { PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::ReadOwnMentorStatus, + PermissionsEnum::UpdateMentors, PermissionsEnum::VerifyMentors, PermissionsEnum::DeleteMentors, ] { diff --git a/imphnen-backend/src/bin/seed_roles.rs b/imphnen-backend/src/bin/seed_roles.rs index e4862ca..7764895 100644 --- a/imphnen-backend/src/bin/seed_roles.rs +++ b/imphnen-backend/src/bin/seed_roles.rs @@ -1,21 +1,19 @@ -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, Env}; +use imphnen_utils::{get_iso_date}; use serde_json::json; use std::error::Error; use surrealdb::engine::any; use surrealdb::opt::auth::Root; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { username: &env.surrealdb_username, password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; let roles = vec![ diff --git a/imphnen-backend/src/bin/seed_roles_permissions.rs b/imphnen-backend/src/bin/seed_roles_permissions.rs index 57b8904..c071460 100644 --- a/imphnen-backend/src/bin/seed_roles_permissions.rs +++ b/imphnen-backend/src/bin/seed_roles_permissions.rs @@ -1,22 +1,28 @@ -use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum}; -use imphnen_libs::enviroment::load_env; +use imphnen_iam::{get_iso_date, make_thing, PermissionsEnum}; use std::error::Error; use surrealdb::engine::any; use surrealdb::opt::auth::Root; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { username: &env.surrealdb_username, password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; + db.query("DEFINE INDEX user_email_index ON TABLE users COLUMNS email UNIQUE;") + + .await?; + db.query("DEFINE INDEX role_name_idx ON TABLE roles COLUMNS name UNIQUE;") + + .await?; + + println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'."); let roles_permissions = vec![ ( @@ -59,6 +65,7 @@ async fn main() -> Result<(), Box> { ( "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", vec![ + PermissionsEnum::ReadListUsers, // Added ReadListUsers permission PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::ReadOwnMentorStatus, @@ -76,6 +83,8 @@ async fn main() -> Result<(), Box> { vec![ PermissionsEnum::ReadListGachaItems, PermissionsEnum::ReadDetailGachaItems, + PermissionsEnum::ReadListUsers, + PermissionsEnum::ReadDetailUsers, PermissionsEnum::CreateGachaClaims, PermissionsEnum::ReadDetailGachaClaims, PermissionsEnum::ReadDetailGachaRolls, diff --git a/imphnen-backend/src/bin/seed_users.rs b/imphnen-backend/src/bin/seed_users.rs index 05468ec..50db3f4 100644 --- a/imphnen-backend/src/bin/seed_users.rs +++ b/imphnen-backend/src/bin/seed_users.rs @@ -1,13 +1,11 @@ use imphnen_iam::UsersSchema; -use imphnen_libs::enviroment::load_env; -use imphnen_utils::{get_iso_date, hash_password, Env}; +use imphnen_utils::{get_iso_date, hash_password}; use std::error::Error; use surrealdb::{opt::auth::Root, sql::Thing}; #[tokio::main] async fn main() -> Result<(), Box> { - load_env(); - let env = Env::new(); + let env = &imphnen_libs::enviroment::ENV; use surrealdb::engine::any; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { @@ -15,8 +13,8 @@ async fn main() -> Result<(), Box> { password: &env.surrealdb_password, }) .await?; - db.use_ns(env.surrealdb_namespace) - .use_db(env.surrealdb_dbname) + db.use_ns(env.surrealdb_namespace.clone()) + .use_db(env.surrealdb_dbname.clone()) .await?; let users = vec![ @@ -48,15 +46,32 @@ async fn main() -> Result<(), Box> { let user = UsersSchema { id: Thing::from(("app_users", id)), fullname: fullname.into(), + legal_name: Some(format!("{} Legal Name", fullname)), email: email.into(), password: hash_password("password").unwrap(), - avatar: None, + avatar: Some("https://example.com/avatar.jpg".into()), phone_number: "081234567890".into(), + phone_for_verification: Some("081234567890".into()), is_active: true, is_deleted: false, mentor_id: None, - gender: None, - birthdate: None, + gender: Some("male".into()), + birthdate: Some("1990-05-15".into()), + domicile: Some("Jakarta, Indonesia".into()), + // identity_document_url: None, // Sudah tidak dipakai, bisa dihapus dari schema jika tidak diperlukan + bio: Some(format!("{} adalah user dengan data pribadi lengkap untuk testing.", fullname)), + last_education: Some("S1 Teknik Informatika".into()), + linkedin_url: Some("https://linkedin.com/in/user".into()), + github_url: Some("https://github.com/user".into()), + cv_url: Some("https://example.com/cv.pdf".into()), + portfolio_url: Some("https://example.com/portfolio".into()), + website_url: Some("https://example.com/website".into()), + twitter_url: Some("https://twitter.com/user".into()), + location: Some("Jakarta, Indonesia".into()), + skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]), + experience: None, + education: None, + career_status: Some("Senior Developer".into()), role: Thing::from(("app_roles", role_id)), created_at: get_iso_date(), updated_at: get_iso_date(), diff --git a/imphnen-cms/src/v1/landing/events/events_repository.rs b/imphnen-cms/src/v1/landing/events/events_repository.rs index c4ed1f2..d1b4977 100644 --- a/imphnen-cms/src/v1/landing/events/events_repository.rs +++ b/imphnen-cms/src/v1/landing/events/events_repository.rs @@ -1,9 +1,10 @@ use super::{events_dto::EventsQueryDto, events_schema::EventsSchema}; use anyhow::{Result, bail}; use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto}; -use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date}; +use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date, make_thing}; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct EventsRepository<'a> { state: &'a AppState, @@ -25,6 +26,7 @@ impl<'a> EventsRepository<'a> { .with_pagination(meta.page, Some(10)) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .build(); + info!(query = %query, "Executing SurrealDB query"); let res: Vec = self.state.surrealdb_ws.query(query).await?.take(0)?; let elapsed = now.elapsed(); @@ -44,10 +46,20 @@ impl<'a> EventsRepository<'a> { pub async fn query_event_by_id(&self, id: String) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + // Attempt to parse the ID. If it's a full Thing (e.g., "events:some_id"), extract the ID part. + // Otherwise, assume it's already the raw ID. + let parsed_id = if id.contains(":") { + let thing = make_thing(ResourceEnum::Events.to_string().as_str(), &id); + get_id(&thing)?.1.to_string() + } else { + id.clone() + }; + let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string()) - .with_id(&id) + .with_id(&parsed_id) .with_select_fields(vec!["*"]); let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query"); let result: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -72,6 +84,8 @@ impl<'a> EventsRepository<'a> { pub async fn query_create_event(&self, data: EventsSchema) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + let query_str = format!("CREATE {} CONTENT ...", ResourceEnum::Events.to_string()); + info!(query = %query_str, "Executing SurrealDB query"); let record: Option = db .create(ResourceEnum::Events.to_string()) .content(data) @@ -106,6 +120,8 @@ impl<'a> EventsRepository<'a> { }; let record_key = get_id(&merged.id)?; + let query_str = format!("UPDATE {:?} MERGE ...", record_key); + info!(query = %query_str, "Executing SurrealDB query"); let record: Option = db.update(record_key).merge(merged).await?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) @@ -130,6 +146,8 @@ impl<'a> EventsRepository<'a> { } let record_key = get_id(&event.id)?; + let query_str = format!("UPDATE {:?} MERGE {{ is_deleted: true }}", record_key); + info!(query = %query_str, "Executing SurrealDB query"); let record: Option = db .update(record_key) .merge(serde_json::json!({ "is_deleted": true })) diff --git a/imphnen-cms/src/v1/landing/testimonials/testimonials_repository.rs b/imphnen-cms/src/v1/landing/testimonials/testimonials_repository.rs index cdb81ac..502800f 100644 --- a/imphnen-cms/src/v1/landing/testimonials/testimonials_repository.rs +++ b/imphnen-cms/src/v1/landing/testimonials/testimonials_repository.rs @@ -7,6 +7,7 @@ use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date}; use serde_json; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct TestimonialsRepository<'a> { state: &'a AppState, @@ -28,6 +29,7 @@ impl<'a> TestimonialsRepository<'a> { .with_pagination(meta.page, Some(10)) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .build(); + info!(query = %query, "Executing SurrealDB query"); let res: Vec = self.state.surrealdb_ws.query(query).await?.take(0)?; let elapsed = now.elapsed(); @@ -55,6 +57,7 @@ impl<'a> TestimonialsRepository<'a> { .with_condition("is_deleted = false") .with_select_fields(vec!["*", "user.* as user"]); let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query"); let result: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -79,9 +82,14 @@ impl<'a> TestimonialsRepository<'a> { pub async fn query_create_testimonial( &self, data: TestimonialsSchema, - ) -> Result { + ) -> Result { // Change return type from String to TestimonialsSchema let now = Instant::now(); let db = &self.state.surrealdb_ws; + info!( + resource = %ResourceEnum::Testimonials.to_string(), + payload = ?data, + "Executing SurrealDB create" + ); let record: Option = db .create(ResourceEnum::Testimonials.to_string()) .content(data) @@ -94,7 +102,7 @@ impl<'a> TestimonialsRepository<'a> { } match record { - Some(_) => Ok("Success create testimonial".into()), + Some(created_testimonial) => Ok(created_testimonial), // Return the created testimonial None => bail!("Failed to create testimonial"), } } @@ -120,6 +128,11 @@ impl<'a> TestimonialsRepository<'a> { }; let record_key = get_id(&merged.id)?; + info!( + record_key = ?record_key, + payload = ?merged, + "Executing SurrealDB update" + ); let record: Option = db.update(record_key).merge(merged).await?; let elapsed = now.elapsed(); @@ -145,6 +158,10 @@ impl<'a> TestimonialsRepository<'a> { } let record_key = get_id(&testimonial.id)?; + info!( + record_key = ?record_key, + "Executing SurrealDB soft delete" + ); let record: Option = db .update(record_key) .merge(serde_json::json!({ "is_deleted": true })) diff --git a/imphnen-cms/src/v1/landing/testimonials/testimonials_service.rs b/imphnen-cms/src/v1/landing/testimonials/testimonials_service.rs index 87de012..7dbb65a 100644 --- a/imphnen-cms/src/v1/landing/testimonials/testimonials_service.rs +++ b/imphnen-cms/src/v1/landing/testimonials/testimonials_service.rs @@ -11,7 +11,7 @@ use imphnen_libs::{ AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, }; use imphnen_utils::{ - common_response, success_list_response, success_response, validate_request, + common_response, success_list_response, success_response, success_created_response, validate_request, }; pub struct TestimonialsService; @@ -46,8 +46,8 @@ impl TestimonialsService { Ok(testimonial) if !testimonial.is_deleted => { success_response(ResponseSuccessDto { data: TestimonialsDetailItemDto { - id: testimonial.id.id.to_raw(), - user_id: testimonial.user.id.id.to_raw(), + id: testimonial.id.to_raw(), + user_id: testimonial.user.id.to_raw(), user_fullname: testimonial.user.fullname, role: testimonial.role, content: testimonial.content, @@ -72,7 +72,19 @@ impl TestimonialsService { let repo = TestimonialsRepository::new(state); let schema = TestimonialsSchema::create(payload, &authenticated_user.id); match repo.query_create_testimonial(schema).await { - Ok(msg) => common_response(StatusCode::CREATED, &msg), + Ok(created_testimonial) => { + success_created_response(ResponseSuccessDto { + data: TestimonialsDetailItemDto { + id: created_testimonial.id.to_raw(), + user_id: created_testimonial.user.id.to_raw(), + user_fullname: authenticated_user.fullname.clone(), + role: created_testimonial.role, + content: created_testimonial.content, + created_at: created_testimonial.created_at, + updated_at: created_testimonial.updated_at, + }, + }) + } Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs b/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs index a3b83a4..1fdbe45 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_controller.rs @@ -57,13 +57,13 @@ pub async fn get_mentor_list( Query(meta): Query, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers, + Extension(app_state), vec![PermissionsEnum::ReadListMentors], ) .await { - Ok(_) => MentorsService::get_mentor_list(&app_state, meta).await, + Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await, Err(response) => response, } } @@ -90,13 +90,13 @@ pub async fn get_mentor_by_id( Path(id): Path, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers, + Extension(app_state), vec![PermissionsEnum::ReadDetailMentors], ) .await { - Ok(_) => MentorsService::get_mentor_by_id(&app_state, &id).await, + Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await, Err(response) => response, } } @@ -126,13 +126,13 @@ pub async fn put_update_mentor( Json(dto): Json, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers, + Extension(app_state), vec![PermissionsEnum::UpdateMentors], ) .await { - Ok(_) => MentorsService::update_mentor(&app_state, &id, dto).await, + Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await, Err(response) => response, } } @@ -159,13 +159,13 @@ pub async fn delete_mentor( Path(id): Path, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers, + Extension(app_state), vec![PermissionsEnum::DeleteMentors], ) .await { - Ok(_) => MentorsService::delete_mentor(&app_state, &id).await, + Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await, Err(response) => response, } } @@ -195,13 +195,13 @@ pub async fn put_verify_mentor( Json(dto): Json, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers, + Extension(app_state), vec![PermissionsEnum::VerifyMentors], ) .await { - Ok(_) => MentorsService::verify_mentor(&app_state, &id, dto).await, + Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await, Err(response) => response, } } @@ -221,17 +221,17 @@ pub async fn put_verify_mentor( ) )] pub async fn get_mentor_me( - Extension(app_state): Extension, headers: HeaderMap, + Extension(app_state): Extension, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers.clone(), + Extension(app_state), vec![PermissionsEnum::ReadOwnMentorProfile], ) .await { - Ok(_) => { + Ok((_user, app_state)) => { let email = match extract_email(&headers) { Some(email) => email, None => { @@ -268,18 +268,18 @@ pub async fn get_mentor_me( ) )] pub async fn put_update_mentor_me( - Extension(app_state): Extension, headers: HeaderMap, + Extension(app_state): Extension, Json(dto): Json, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers.clone(), + Extension(app_state), vec![PermissionsEnum::UpdateOwnMentorProfile], ) .await { - Ok(_) => { + Ok((_user, app_state)) => { let email = match extract_email(&headers) { Some(email) => email, None => { @@ -325,17 +325,17 @@ pub async fn put_update_mentor_no_id() -> Response { ) )] pub async fn get_mentor_status( - Extension(app_state): Extension, headers: HeaderMap, + Extension(app_state): Extension, ) -> Response { match permissions_guard( - &headers, - app_state.clone(), + headers.clone(), + Extension(app_state), vec![PermissionsEnum::ReadOwnMentorStatus], ) .await { - Ok(_) => { + Ok((_user, app_state)) => { let email = match extract_email(&headers) { Some(email) => email, None => { diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs b/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs index a7ebbe3..549edc2 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_dto.rs @@ -18,15 +18,9 @@ pub struct MentorListResponseDto { pub struct MentorDetailWithUserDto { pub id: Thing, pub user_id: Thing, - pub fullname: Option, - pub email: Option, - pub legal_name: String, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, + // Personal data is now in UsersSchema, access via user_id + // Removed: fullname, email, legal_name, identity_document_url, + // phone_for_verification, bio, linkedin_url, github_url, cv_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -47,15 +41,20 @@ pub struct MentorDetailWithUserDto { pub struct MentorDetailResponseDto { pub id: String, pub user_id: String, + // Personal data fields from UsersSchema pub fullname: Option, pub email: Option, - pub legal_name: String, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, + pub legal_name: Option, + pub gender: Option, + pub domicile: Option, + pub phone_for_verification: Option, + pub bio: Option, + pub last_education: Option, pub linkedin_url: Option, pub github_url: Option, pub cv_url: Option, + pub portfolio_url: Option, + // Professional data from MentorSchema pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -94,9 +93,6 @@ pub struct MentorUpdateRequestDto { pub gender: Option, #[serde(skip_serializing_if = "Option::is_none")] pub domicile: Option, - #[validate(url(message = "Invalid identity document URL"))] - #[serde(skip_serializing_if = "Option::is_none")] - pub identity_document_url: Option, #[validate(length( min = 10, max = 15, @@ -118,6 +114,7 @@ pub struct MentorUpdateRequestDto { #[validate(url(message = "Invalid CV URL"))] #[serde(skip_serializing_if = "Option::is_none")] pub cv_url: Option, + #[validate(url(message = "Invalid portfolio URL"))] #[serde(skip_serializing_if = "Option::is_none")] pub portfolio_url: Option, #[validate(length(min = 1, message = "At least 1 industry required"))] @@ -279,18 +276,7 @@ pub struct MentoringRate { pub struct MentorInsertDto { pub id: Thing, pub user_id: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data removed - now stored in UsersSchema pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -313,18 +299,7 @@ impl From for MentorInsertDto { MentorInsertDto { id: schema.id, user_id: schema.user_id, - email: schema.email, - legal_name: schema.legal_name, - gender: schema.gender, - domicile: schema.domicile, - identity_document_url: schema.identity_document_url, - phone_for_verification: schema.phone_for_verification, - bio: schema.bio, - last_education: schema.last_education, - linkedin_url: schema.linkedin_url, - github_url: schema.github_url, - cv_url: schema.cv_url, - portfolio_url: schema.portfolio_url, + // Personal data removed from MentorSchema industries: schema.industries, expertise: schema.expertise, languages: schema.languages, @@ -354,19 +329,10 @@ pub struct MentorVerifyRequestDto { pub struct MentorDetailQueryDto { pub id: Thing, pub user_id: Thing, - pub fullname: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data has been moved to UsersSchema + // Use user_id to get: fullname, email, legal_name, gender, domicile, + // identity_document_url, phone_for_verification, bio, last_education, + // linkedin_url, github_url, cv_url, portfolio_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -388,8 +354,8 @@ impl From for MentorListResponseDto { fn from(dto: MentorDetailQueryDto) -> Self { Self { id: extract_id(&dto.id), - fullname: dto.fullname, - email: dto.email, + fullname: None, // now in user table, must be populated from service layer + email: None, // now in user table, must be populated from service layer status: dto.status, created_at: dto.created_at, updated_at: dto.updated_at, @@ -402,15 +368,20 @@ impl From for MentorDetailResponseDto { Self { id: extract_id(&dto.id), user_id: extract_id(&dto.user_id), - fullname: dto.fullname, - email: dto.email, - legal_name: dto.legal_name, - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, + // Personal data fields are populated in service layer from UsersSchema + fullname: None, // populated from user table in service layer + email: None, // populated from user table in service layer + legal_name: None, // populated from user table in service layer + gender: None, // populated from user table in service layer + domicile: None, // populated from user table in service layer + phone_for_verification: None, // populated from user table in service layer + bio: None, // populated from user table in service layer + last_education: None, // populated from user table in service layer + linkedin_url: None, // populated from user table in service layer + github_url: None, // populated from user table in service layer + cv_url: None, // populated from user table in service layer + portfolio_url: None, // populated from user table in service layer + // Professional data from mentor industries: dto.industries, expertise: dto.expertise, languages: dto.languages, @@ -433,7 +404,7 @@ impl From for MentorRegisterResponseDto { Self { id: schema.id.to_string(), user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(), - email: schema.email, + email: None, // schema.email - now in user table status: schema.status, created_at: schema.created_at, updated_at: schema.updated_at, @@ -446,19 +417,7 @@ impl From for MentorDetailQueryDto { MentorDetailQueryDto { id: dto.id, user_id: dto.user_id, - fullname: dto.fullname, - email: dto.email, - legal_name: dto.legal_name, - gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, - portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto + // Personal data removed from MentorDetailWithUserDto - now in UsersSchema industries: dto.industries, expertise: dto.expertise, languages: dto.languages, diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs b/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs index fbe673e..54d103f 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_repository.rs @@ -8,6 +8,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date}; use serde_json::{Map, Value}; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct MentorsRepository<'a> { pub state: &'a AppState, @@ -27,19 +28,11 @@ impl<'a> MentorsRepository<'a> { let db = &self.state.surrealdb_ws; let mentors_table = ResourceEnum::Mentors.to_string(); let builder = QueryListBuilder::new(db, &mentors_table, &meta) - .search_field("legal_name") + .search_field("user_id.legal_name") // Search in user data instead .select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", @@ -79,19 +72,11 @@ impl<'a> MentorsRepository<'a> { let now = Instant::now(); let db = &self.state.surrealdb_ws; let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string()) - .with_where("email", Some(email.clone())) + .with_where("user_id.email", Some(email.clone())) // Search in user table .with_select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", @@ -114,6 +99,7 @@ impl<'a> MentorsRepository<'a> { } let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query in query_mentor_by_email"); let mentor_opt: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -141,15 +127,7 @@ impl<'a> MentorsRepository<'a> { .with_select_fields(vec![ "id", "user_id", - "user_id.fullname as fullname", - "email", - "legal_name", - "identity_document_url", - "phone_for_verification", - "bio", - "linkedin_url", - "github_url", - "cv_url", + // Personal data comes from user relation, not mentor table "industries", "expertise", "languages", @@ -172,6 +150,7 @@ impl<'a> MentorsRepository<'a> { } let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query in query_mentor_by_id"); let mentor_opt: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -194,8 +173,10 @@ impl<'a> MentorsRepository<'a> { let now = Instant::now(); let db = &self.state.surrealdb_ws; let dto: MentorInsertDto = data.into(); + let resource = ResourceEnum::Mentors.to_string(); + info!(query = %resource, "Executing SurrealDB create in query_create_mentor"); let record: Option = db - .create(ResourceEnum::Mentors.to_string()) + .create(resource) .content(dto.clone()) .await?; let elapsed = now.elapsed(); @@ -237,6 +218,7 @@ impl<'a> MentorsRepository<'a> { merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date())); + info!(query = ?record_key, "Executing SurrealDB update in query_update_mentor"); let record: Option = db.update(record_key).merge(merged_data_json).await?; let elapsed = now.elapsed(); @@ -282,6 +264,7 @@ impl<'a> MentorsRepository<'a> { patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("updated_at".to_string(), Value::String(get_iso_date())); + info!(query = ?record_key, "Executing SurrealDB soft delete in query_delete_mentor"); let record: Option = db.update(record_key).merge(patch).await?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs b/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs index b9c7b2d..f61d696 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_schema.rs @@ -1,5 +1,5 @@ use super::{ - IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto, + MentorDetailQueryDto, MentorUpdateRequestDto, MentoringLogistics, MentoringRate, ProfessionalProfile, }; use imphnen_libs::ResourceEnum; @@ -12,18 +12,9 @@ pub struct MentorSchema { pub id: Thing, #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, - pub email: Option, - pub legal_name: String, - pub gender: Option, - pub domicile: Option, - pub identity_document_url: String, - pub phone_for_verification: String, - pub bio: String, - pub last_education: Option, - pub linkedin_url: Option, - pub github_url: Option, - pub cv_url: Option, - pub portfolio_url: Option, + // Personal data has been moved to UsersSchema - use user_id to reference + // phone_for_verification, bio, last_education, linkedin_url, github_url, + // cv_url, portfolio_url pub industries: Vec, pub expertise: Vec, pub languages: Vec, @@ -52,18 +43,6 @@ impl Default for MentorSchema { ResourceEnum::Users.to_string().as_str(), &Uuid::new_v4().to_string(), )), - email: None, - legal_name: String::new(), - gender: None, - domicile: None, - identity_document_url: String::new(), - phone_for_verification: String::new(), - bio: String::new(), - last_education: None, - linkedin_url: None, - github_url: None, - cv_url: None, - portfolio_url: None, industries: Vec::new(), expertise: Vec::new(), languages: Vec::new(), @@ -89,11 +68,9 @@ impl Default for MentorSchema { impl MentorSchema { pub fn create( - identity_and_verification: IdentityAndVerification, professional_profile: ProfessionalProfile, mentoring_logistics: MentoringLogistics, user_id_raw: String, - email_str: String, ) -> Self { Self { id: make_thing( @@ -101,18 +78,7 @@ impl MentorSchema { &Uuid::new_v4().to_string(), ), user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)), - email: Some(email_str), - legal_name: identity_and_verification.legal_name, - gender: identity_and_verification.gender, - domicile: identity_and_verification.domicile, - identity_document_url: identity_and_verification.identity_document_url, - phone_for_verification: identity_and_verification.phone_for_verification, - bio: professional_profile.bio, - last_education: professional_profile.last_education, - linkedin_url: professional_profile.linkedin_url, - github_url: professional_profile.github_url, - cv_url: professional_profile.cv_url, - portfolio_url: professional_profile.portfolio_url, + // Personal data now stored in UsersSchema, not here industries: professional_profile.industries, expertise: professional_profile.expertise, languages: professional_profile.languages, @@ -139,18 +105,7 @@ impl MentorSchema { Self { id: dto.id, user_id: Some(dto.user_id), - email: dto.email, - legal_name: dto.legal_name, - gender: dto.gender, - domicile: dto.domicile, - identity_document_url: dto.identity_document_url, - phone_for_verification: dto.phone_for_verification, - bio: dto.bio, - last_education: dto.last_education, - linkedin_url: dto.linkedin_url, - github_url: dto.github_url, - cv_url: dto.cv_url, - portfolio_url: dto.portfolio_url, + // Personal data now comes from UsersSchema via user_id industries: dto.industries, expertise: dto.expertise, languages: dto.languages, @@ -170,40 +125,10 @@ impl MentorSchema { } pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self { - // Update fields only if they are Some(value), otherwise preserve current value - if let Some(val) = dto.legal_name { - self.legal_name = val; - } - if let Some(val) = dto.gender { - self.gender = Some(val); - } - if let Some(val) = dto.domicile { - self.domicile = Some(val); - } - if let Some(val) = dto.identity_document_url { - self.identity_document_url = val; - } - if let Some(val) = dto.phone_for_verification { - self.phone_for_verification = val; - } - if let Some(val) = dto.bio { - self.bio = val; - } - if let Some(val) = dto.last_education { - self.last_education = Some(val); - } - if let Some(val) = dto.linkedin_url { - self.linkedin_url = Some(val); - } - if let Some(val) = dto.github_url { - self.github_url = Some(val); - } - if let Some(val) = dto.cv_url { - self.cv_url = Some(val); - } - if let Some(val) = dto.portfolio_url { - self.portfolio_url = Some(val); - } + // phone_for_verification, bio, last_education, linkedin_url, github_url, + // cv_url, portfolio_url) are now updated in UsersSchema, not here + + // Only update professional fields that are still in MentorSchema if let Some(val) = dto.industries { self.industries = val; } diff --git a/imphnen-dimentorin/src/v1/mentors/mentors_service.rs b/imphnen-dimentorin/src/v1/mentors/mentors_service.rs index 6c8d633..44ad29c 100644 --- a/imphnen-dimentorin/src/v1/mentors/mentors_service.rs +++ b/imphnen-dimentorin/src/v1/mentors/mentors_service.rs @@ -60,6 +60,18 @@ impl MentorsService { user_schema.fullname = dto.fullname.clone(); user_schema.phone_number = dto.phone_number.clone(); + // Update personal data from identity_and_verification + user_schema.legal_name = Some(dto.identity_and_verification.legal_name.clone()); + user_schema.gender = dto.identity_and_verification.gender.clone(); + user_schema.domicile = dto.identity_and_verification.domicile.clone(); + user_schema.phone_for_verification = Some(dto.identity_and_verification.phone_for_verification.clone()); + // Update personal data from professional_profile + user_schema.bio = Some(dto.professional_profile.bio.clone()); + user_schema.last_education = dto.professional_profile.last_education.clone(); + user_schema.linkedin_url = dto.professional_profile.linkedin_url.clone(); + user_schema.github_url = dto.professional_profile.github_url.clone(); + user_schema.cv_url = dto.professional_profile.cv_url.clone(); + user_schema.portfolio_url = dto.professional_profile.portfolio_url.clone(); user_schema.updated_at = imphnen_utils::get_iso_date(); let hashed_password = match imphnen_utils::hash_password(&dto.password) { @@ -135,6 +147,18 @@ impl MentorsService { fullname: dto.fullname.clone(), password: hashed_password, phone_number: dto.phone_number.clone(), + // Store personal data from identity_and_verification in user + legal_name: Some(dto.identity_and_verification.legal_name.clone()), + gender: dto.identity_and_verification.gender.clone(), + domicile: dto.identity_and_verification.domicile.clone(), + phone_for_verification: Some(dto.identity_and_verification.phone_for_verification.clone()), + // Store personal data from professional_profile in user + bio: Some(dto.professional_profile.bio.clone()), + last_education: dto.professional_profile.last_education.clone(), + linkedin_url: dto.professional_profile.linkedin_url.clone(), + github_url: dto.professional_profile.github_url.clone(), + cv_url: dto.professional_profile.cv_url.clone(), + portfolio_url: dto.professional_profile.portfolio_url.clone(), created_at: imphnen_utils::get_iso_date(), updated_at: imphnen_utils::get_iso_date(), role: imphnen_utils::make_thing( @@ -187,11 +211,9 @@ impl MentorsService { } let mentor_schema = MentorSchema::create( - dto.identity_and_verification, dto.professional_profile, dto.mentoring_logistics, user_id.to_raw(), - final_user_email.clone(), ); match mentor_repo.query_create_mentor(mentor_schema.clone()).await { @@ -233,16 +255,27 @@ impl MentorsService { pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response { let repo = MentorsRepository::new(state); + let user_repo = UsersRepository::new(state); + match repo.query_mentor_list(meta).await { Ok(result) => { - let data: Vec = result - .data - .into_iter() - .map(MentorDetailQueryDto::from) - .map(MentorListResponseDto::from) - .collect(); + let mut mentor_list_data: Vec = Vec::new(); + + for mentor_with_user in result.data { + let mentor_dto = MentorDetailQueryDto::from(mentor_with_user); + let mut list_item = MentorListResponseDto::from(mentor_dto.clone()); + + // Get user data to populate personal fields + if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await { + list_item.fullname = Some(user.fullname); + list_item.email = Some(user.email); + } + + mentor_list_data.push(list_item); + } + success_list_response(ResponseListSuccessDto { - data, + data: mentor_list_data, meta: result.meta, }) } @@ -251,12 +284,56 @@ impl MentorsService { } pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response { - let repo = MentorsRepository::new(state); + let mentor_repo = MentorsRepository::new(state); + let user_repo = UsersRepository::new(state); let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id)); - match repo.query_mentor_by_id(&thing_id, false).await { + + match mentor_repo.query_mentor_by_id(&thing_id, false).await { Ok(mentor) => { - let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor)); - success_response(ResponseSuccessDto { data: dto }) + // Get user data separately + let user_result = user_repo.query_user_by_id(&mentor.user_id).await; + match user_result { + Ok(user) => { + // Combine mentor and user data + let dto = MentorDetailResponseDto { + id: mentor.id.to_raw(), + user_id: mentor.user_id.to_raw(), + // Personal data from user + fullname: Some(user.fullname), + email: Some(user.email), + legal_name: user.legal_name, + gender: user.gender, + domicile: user.domicile, + phone_for_verification: user.phone_for_verification, + bio: user.bio, + last_education: user.last_education, + linkedin_url: user.linkedin_url, + github_url: user.github_url, + cv_url: user.cv_url, + portfolio_url: user.portfolio_url, + // Professional data from mentor + industries: mentor.industries, + expertise: mentor.expertise, + languages: mentor.languages, + current_company: mentor.current_company, + current_role: mentor.current_role, + years_of_experience: mentor.years_of_experience, + topics_of_interest: mentor.topics_of_interest, + preferred_mentee_level: mentor.preferred_mentee_level, + preferred_mentoring_formats: mentor.preferred_mentoring_formats, + availability_commitment: mentor.availability_commitment, + mentoring_rate: mentor.mentoring_rate, + status: mentor.status, + created_at: mentor.created_at, + updated_at: mentor.updated_at, + }; + success_response(ResponseSuccessDto { data: dto }) + } + Err(_e) => { + error!("Failed to get user data for mentor {}: {}", id, _e); + common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get mentor user data") + } + } } Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()), } diff --git a/imphnen-dimentorin/src/v1/mentors/mod.rs b/imphnen-dimentorin/src/v1/mentors/mod.rs index d530b75..63f2c94 100644 --- a/imphnen-dimentorin/src/v1/mentors/mod.rs +++ b/imphnen-dimentorin/src/v1/mentors/mod.rs @@ -26,5 +26,5 @@ pub fn mentors_router() -> Router { .route("/update/{id}", put(put_update_mentor)) .route("/update", put(put_update_mentor_no_id)) .route("/delete/{id}", delete(delete_mentor)) - .route("/verify/{id}", put(put(put_verify_mentor))) + .route("/verify/{id}", put(put_verify_mentor)) } diff --git a/imphnen-entities/Cargo.toml b/imphnen-entities/Cargo.toml index 6db179b..cb4c0af 100644 --- a/imphnen-entities/Cargo.toml +++ b/imphnen-entities/Cargo.toml @@ -8,4 +8,5 @@ axum.workspace = true serde.workspace = true utoipa.workspace = true surrealdb.workspace = true +anyhow.workspace = true thiserror.workspace = true diff --git a/imphnen-entities/src/error_dto.rs b/imphnen-entities/src/error_dto.rs index 1ed0813..271846b 100644 --- a/imphnen-entities/src/error_dto.rs +++ b/imphnen-entities/src/error_dto.rs @@ -9,6 +9,14 @@ pub mod error { pub enum Error { #[error("database error: {0}")] Db(String), + #[error("anyhow error: {0}")] + Anyhow(#[from] anyhow::Error), + #[error("HTTP status code error: {0}")] + StatusCode(StatusCode), + #[error("authentication error: {0}")] + Auth(String), + #[error("validation error: {0}")] + Validation(String), } impl IntoResponse for Error { @@ -18,6 +26,19 @@ pub mod error { StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {detail}"), ), + Error::Anyhow(detail) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Internal server error: {detail}"), + ), + Error::StatusCode(s) => (s, format!("HTTP error: {}", s)), + Error::Auth(detail) => ( + StatusCode::UNAUTHORIZED, + format!("Authentication error: {detail}"), + ), + Error::Validation(detail) => ( + StatusCode::BAD_REQUEST, + format!("Validation error: {detail}"), + ), }; (status, Json(error_message)).into_response() } @@ -28,4 +49,10 @@ pub mod error { Self::Db(error.to_string()) } } + + impl From for Error { + fn from(status: StatusCode) -> Self { + Self::StatusCode(status) + } + } } diff --git a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_controller.rs b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_controller.rs index 3503e84..554e93d 100644 --- a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_controller.rs +++ b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_controller.rs @@ -25,13 +25,13 @@ pub async fn get_detail_gacha_claim( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), - vec![PermissionsEnum::CreateGachaClaims], + headers, + Extension(state), + vec![PermissionsEnum::ReadDetailGachaClaims], ) .await { - Ok(_) => GachaClaimService::get_gacha_claim_by_id(&state, id).await, + Ok((_user, state)) => GachaClaimService::get_gacha_claim_by_id(&state, id).await, Err(response) => response, } } @@ -54,13 +54,13 @@ pub async fn post_create_gacha_claim( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreateGachaClaims], ) .await { - Ok(_) => GachaClaimService::create_gacha_claim(&state, payload).await, + Ok((_user, state)) => GachaClaimService::create_gacha_claim(&state, payload).await, Err(response) => response, } } diff --git a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_repository.rs b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_repository.rs index 6016bd3..b8975b2 100644 --- a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_repository.rs +++ b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_repository.rs @@ -3,7 +3,7 @@ use crate::{AppState, ResourceEnum}; use anyhow::{Result, bail}; use imphnen_iam::DetailQueryBuilder; use std::time::Instant; -use tracing::instrument; +use tracing::{instrument, info}; pub struct GachaClaimRepository<'a> { state: &'a AppState, @@ -27,6 +27,7 @@ impl<'a> GachaClaimRepository<'a> { .with_fetch("item") .with_fetch("user"); let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query"); let result: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -48,6 +49,11 @@ impl<'a> GachaClaimRepository<'a> { ) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + info!( + resource = %ResourceEnum::GachaClaims.to_string(), + content = ?data, + "Executing SurrealDB create query" + ); let record: Option = db .create(ResourceEnum::GachaClaims.to_string()) .content(data) diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs index 21962bb..7719af6 100644 --- a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs @@ -4,7 +4,7 @@ use anyhow::{Result, bail}; use imphnen_iam::make_thing; use std::time::Instant; use surrealdb::Uuid; -use tracing::instrument; +use tracing::{instrument, info}; pub struct GachaCreditRepository<'a> { state: &'a AppState, @@ -27,6 +27,7 @@ impl<'a> GachaCreditRepository<'a> { ResourceEnum::GachaCredits, ResourceEnum::Users ); + info!(query = %sql, "Executing SurrealDB query"); let result: Vec = db.query(sql).bind(("user_id", user_id)).await?.take(0)?; let elapsed = now.elapsed(); @@ -66,6 +67,7 @@ impl<'a> GachaCreditRepository<'a> { bail!("No extra roll credits remaining"); } credit.available_rolls -= 1; + info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for consume_credit"); let _: Option = db .update(( &ResourceEnum::GachaCredits.to_string(), @@ -91,6 +93,7 @@ impl<'a> GachaCreditRepository<'a> { let db = &self.state.surrealdb_ws; if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? { credit.available_rolls += payload.amount; + info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for add_credit"); let _: Option = db .update(( &ResourceEnum::GachaCredits.to_string(), @@ -108,6 +111,7 @@ impl<'a> GachaCreditRepository<'a> { available_rolls: payload.amount, ..Default::default() }; + info!(operation = "create", table = %ResourceEnum::GachaCredits.to_string(), "Executing SurrealDB create for add_credit"); let _: Option = db .create(ResourceEnum::GachaCredits.to_string()) .content(data) diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs index 310d601..ac13f0b 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_controller.rs @@ -1,5 +1,5 @@ use crate::{ - AppState, GachaItemDto, GachaItemRequestDto, GachaItemService, MessageResponseDto, + AppState, GachaItemDto, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemService, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, }; use axum::{ @@ -36,13 +36,13 @@ pub async fn get_gacha_item_list( Query(meta): Query, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadListGachaItems], ) .await { - Ok(_) => GachaItemService::get_gacha_item_list(&state, meta).await, + Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await, Err(response) => response, } } @@ -65,13 +65,13 @@ pub async fn get_gacha_item_by_id( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadDetailGachaItems], ) .await { - Ok(_) => GachaItemService::get_gacha_item_by_id(&state, id).await, + Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await, Err(response) => response, } } @@ -94,13 +94,13 @@ pub async fn post_create_gacha_item( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreateGachaItems], ) .await { - Ok(_) => GachaItemService::create_gacha_item(&state, payload).await, + Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await, Err(response) => response, } } @@ -111,7 +111,7 @@ pub async fn post_create_gacha_item( security( ("Bearer" = []) ), - request_body = GachaItemRequestDto, + request_body = GachaItemUpdateRequestDto, responses( (status = 200, description = "Update gacha item", body = MessageResponseDto) ), @@ -121,16 +121,16 @@ pub async fn put_update_gacha_item( headers: HeaderMap, Extension(state): Extension, Path(id): Path, - Json(payload): Json, + Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::UpdateGachaItems], ) .await { - Ok(_) => GachaItemService::update_gacha_item(&state, payload, id).await, + Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await, Err(response) => response, } } @@ -152,13 +152,13 @@ pub async fn delete_gacha_item( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::DeleteGachaItems], ) .await { - Ok(_) => GachaItemService::delete_gacha_item(&state, id).await, + Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await, Err(response) => response, } } diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs index 589a29f..9c7d823 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs @@ -11,6 +11,16 @@ pub struct GachaItemRequestDto { pub image_url: String, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct GachaItemUpdateRequestDto { + #[validate(length(min = 1, message = "Item name must not be empty"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[validate(length(min = 1, message = "Image URL must not be empty"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct GachaItemDto { pub id: String, diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_repository.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_repository.rs index 73fbee8..a4d188b 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_repository.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_repository.rs @@ -9,6 +9,7 @@ use imphnen_utils::get_iso_date; use serde_json::{Map, Value}; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct GachaItemRepository<'a> { state: &'a AppState, @@ -25,6 +26,11 @@ impl<'a> GachaItemRepository<'a> { meta: MetaRequestDto, ) -> Result>> { let now = Instant::now(); + let surreal_query = format!( + "SELECT * FROM {} WHERE is_deleted = false AND name LIKE ?", + ResourceEnum::GachaItems.to_string() + ); + info!(query = %surreal_query, "Executing SurrealDB query"); let raw_result: ResponseListSuccessDto> = QueryListBuilder::new( &self.state.surrealdb_ws, @@ -57,6 +63,8 @@ impl<'a> GachaItemRepository<'a> { pub async fn query_gacha_item_by_id(&self, id: String) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + let surreal_query = format!("SELECT * FROM {} WHERE id = '{}'", ResourceEnum::GachaItems.to_string(), id); + info!(query = %surreal_query, "Executing SurrealDB query"); let result: Option = db .select((ResourceEnum::GachaItems.to_string(), id.clone())) .await?; @@ -79,6 +87,8 @@ impl<'a> GachaItemRepository<'a> { ) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + let surreal_query = format!("CREATE {} CONTENT ...", ResourceEnum::GachaItems.to_string()); + info!(query = %surreal_query, "Executing SurrealDB query"); let record: Option = db .create(ResourceEnum::GachaItems.to_string()) .content(data) @@ -111,6 +121,8 @@ impl<'a> GachaItemRepository<'a> { created_at: existing.created_at, ..data.clone() }; + let surreal_query = format!("UPDATE {:?} MERGE ...", record_key); + info!(query = %surreal_query, "Executing SurrealDB query"); let record: Option = db.update(record_key).merge(merged).await?; let elapsed = now.elapsed(); @@ -139,6 +151,8 @@ impl<'a> GachaItemRepository<'a> { patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("updated_at".to_string(), Value::String(get_iso_date())); + let surreal_query = format!("UPDATE {:?} MERGE ...", record_key); + info!(query = %surreal_query, "Executing SurrealDB query"); let record: Option = db.update(record_key).merge(patch).await?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_schema.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_schema.rs index 86aedec..bdfd216 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_schema.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_schema.rs @@ -43,4 +43,8 @@ impl GachaItemSchema { ..Default::default() } } + + pub fn from_existing(existing: GachaItemSchema) -> Self { + existing + } } diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs index 0afa2a5..05aabfe 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_service.rs @@ -1,11 +1,12 @@ use crate::{ - AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemSchema, + AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemSchema, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto, common_response, make_thing, success_list_response, success_response, validate_request, }; use axum::http::StatusCode; use axum::response::Response; +use imphnen_utils::get_iso_date; pub struct GachaItemService; @@ -59,20 +60,33 @@ impl GachaItemService { pub async fn update_gacha_item( state: &AppState, - payload: GachaItemRequestDto, + payload: GachaItemUpdateRequestDto, id: String, ) -> Response { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } let repo = GachaItemRepository::new(state); - let schema = GachaItemSchema { - id: make_thing(&ResourceEnum::GachaItems.to_string(), &id), - name: payload.name, - image_url: payload.image_url, - ..Default::default() + + // Get current gacha item data first + let _thing_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id); + let current_item = match repo.query_gacha_item_by_id(id.clone()).await { + Ok(item) => item, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Gacha Item not found"), }; - match repo.query_update_gacha_item(schema).await { + + let mut updated_item = current_item; + updated_item.updated_at = Some(get_iso_date()); + + // Only update fields that are provided + if let Some(name) = payload.name { + updated_item.name = name; + } + if let Some(image_url) = payload.image_url { + updated_item.image_url = image_url; + } + + match repo.query_update_gacha_item(updated_item).await { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => { if e.to_string().contains("not found") { diff --git a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_controller.rs b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_controller.rs index d98a4f9..ad509e6 100644 --- a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_controller.rs +++ b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_controller.rs @@ -25,13 +25,13 @@ pub async fn get_detail_gacha_roll( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadDetailGachaRolls], ) .await { - Ok(_) => GachaRollService::get_gacha_roll_by_id(&state, id).await, + Ok((_user, state)) => GachaRollService::get_gacha_roll_by_id(&state, id).await, Err(response) => response, } } @@ -54,13 +54,13 @@ pub async fn post_create_gacha_roll( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreateGachaRolls], ) .await { - Ok(_) => GachaRollService::create_gacha_roll(&state, payload).await, + Ok((_user, state)) => GachaRollService::create_gacha_roll(&state, payload).await, Err(response) => response, } } @@ -81,13 +81,13 @@ pub async fn post_execute_gacha_roll( Extension(state): Extension, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers.clone(), + Extension(state), vec![PermissionsEnum::ExecuteGachaRolls], ) .await { - Ok(_) => GachaRollService::execute_roll_once(headers, &state).await, + Ok((_user, state)) => GachaRollService::execute_roll_once(headers, &state).await, Err(response) => response, } } @@ -110,13 +110,13 @@ pub async fn delete_gacha_roll( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::DeleteGachaRolls], ) .await { - Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await, + Ok((_user, state)) => GachaRollService::soft_delete_gacha_roll(&state, id).await, Err(response) => response, } } diff --git a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_repository.rs b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_repository.rs index abec2aa..4e8d238 100644 --- a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_repository.rs +++ b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_repository.rs @@ -10,6 +10,7 @@ use rand_distr::weighted::WeightedIndex; use serde_json::{Map, Value}; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct GachaRollRepository<'a> { state: &'a AppState, @@ -33,6 +34,7 @@ impl<'a> GachaRollRepository<'a> { .with_select_fields(vec!["*"]) .with_fetch("item"); let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query"); let result: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -54,6 +56,7 @@ impl<'a> GachaRollRepository<'a> { ) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + info!(query = "CREATE", "Executing SurrealDB create operation for GachaRolls"); let record: Option = db .create(ResourceEnum::GachaRolls.to_string()) .content(data) @@ -77,6 +80,7 @@ impl<'a> GachaRollRepository<'a> { let table_name = ResourceEnum::GachaRolls.to_string(); let sql = format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item"); + info!(query = %sql, "Executing SurrealDB query"); let result: Vec = db.query(sql).await?.take(0)?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) @@ -87,6 +91,7 @@ impl<'a> GachaRollRepository<'a> { Ok(result) } + #[instrument] pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option { let filtered: Vec<_> = rolls .iter() @@ -120,6 +125,7 @@ impl<'a> GachaRollRepository<'a> { patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("updated_at".to_string(), Value::String(get_iso_date())); + info!(query = "UPDATE", record_key = ?record_key, "Executing SurrealDB update operation for GachaRolls"); let record: Option = db.update(record_key).merge(patch).await?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) diff --git a/imphnen-gateway/src/docs.rs b/imphnen-gateway/src/docs.rs index 955b9cc..059b8ca 100644 --- a/imphnen-gateway/src/docs.rs +++ b/imphnen-gateway/src/docs.rs @@ -29,6 +29,7 @@ use imphnen_iam::{ RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users, }; +use imphnen_iam::users::users_controller::FileUploadSchema; use utoipa::{ Modify, OpenApi, openapi::security::{Http, HttpAuthScheme, SecurityScheme}, @@ -53,6 +54,7 @@ use utoipa::{ users::users_controller::get_user_by_id, users::users_controller::get_user_me, users::users_controller::get_user_list, + users::users_controller::upload_file, roles::roles_controller::get_role_list, roles::roles_controller::get_role_by_id, roles::roles_controller::post_create_role, @@ -114,6 +116,7 @@ use utoipa::{ UsersListItemDto, UsersUpdateRequestDto, UsersCreateRequestDto, + FileUploadSchema, GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, diff --git a/imphnen-iam/Cargo.toml b/imphnen-iam/Cargo.toml index 1f2bcb0..6d8212e 100644 --- a/imphnen-iam/Cargo.toml +++ b/imphnen-iam/Cargo.toml @@ -6,10 +6,13 @@ edition = "2024" [dependencies] imphnen-libs.workspace = true imphnen-utils.workspace = true + imphnen-entities.workspace = true axum.workspace = true serde.workspace = true -serde_json.workspace = true +serde_json = { workspace = true } +oauth2 = { workspace = true } +reqwest = { workspace = true, features = ["json"] } utoipa.workspace = true lazy_static.workspace = true regex.workspace = true @@ -28,7 +31,10 @@ log.workspace = true once_cell.workspace = true tracing.workspace = true uuid.workspace = true +axum-extra.workspace = true [dev-dependencies] dotenvy.workspace = true +tokio-test = { workspace = true } +mockall = { workspace = true } http-body-util.workspace = true diff --git a/imphnen-iam/src/v1/auth/auth_controller.rs b/imphnen-iam/src/v1/auth/auth_controller.rs index 6ff9ef6..05be6cb 100644 --- a/imphnen-iam/src/v1/auth/auth_controller.rs +++ b/imphnen-iam/src/v1/auth/auth_controller.rs @@ -1,10 +1,12 @@ use super::{ AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, - AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto, + AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, }; use crate::{AppState, v1::AuthLoginResponsetDto}; use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto}; use axum::{Extension, Json, response::IntoResponse}; +use crate::v1::auth::auth_service::AuthServiceTrait; +use crate::v1::auth::auth_service::AuthService; #[utoipa::path( post, @@ -137,7 +139,8 @@ pub async fn post_new_password( tag = "Authentication" )] pub async fn post_refresh_token( + Extension(state): Extension, Json(payload): Json, ) -> impl IntoResponse { - AuthService::mutation_refresh_token(payload).await + AuthService::mutation_refresh_token(payload, &state).await } diff --git a/imphnen-iam/src/v1/auth/auth_dto.rs b/imphnen-iam/src/v1/auth/auth_dto.rs index d85c198..3d9ec4f 100644 --- a/imphnen-iam/src/v1/auth/auth_dto.rs +++ b/imphnen-iam/src/v1/auth/auth_dto.rs @@ -32,7 +32,7 @@ pub struct AuthLoginResponsetDto { pub user: UsersDetailItemDto, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct TokenDto { pub access_token: String, pub refresh_token: String, @@ -121,4 +121,4 @@ pub struct AuthSetNewPasswordRequestDto { pub struct UserCacheSchema { pub email: String, pub permissions: Vec, -} +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/auth_repository.rs b/imphnen-iam/src/v1/auth/auth_repository.rs index 79d9722..671e740 100644 --- a/imphnen-iam/src/v1/auth/auth_repository.rs +++ b/imphnen-iam/src/v1/auth/auth_repository.rs @@ -7,6 +7,8 @@ use crate::{ use anyhow::{Result, anyhow, bail}; use chrono::{Duration, Utc}; use surrealdb::sql::Thing; +use tracing::instrument; +use tracing::info; pub struct AuthRepository<'a> { pub state: &'a AppState, @@ -17,6 +19,7 @@ impl<'a> AuthRepository<'a> { Self { state } } + #[instrument(skip(self, user), err)] pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result { if user.email.trim().is_empty() { bail!("Email is required"); @@ -30,12 +33,14 @@ impl<'a> AuthRepository<'a> { permissions, }; + info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, user_id), "Executing SurrealDB query"); let _record: Option = self .state .surrealdb_mem .delete::>((table.clone(), user_id.clone())) .await?; + info!(query = %format!("CREATE {}:{}", table, user_id), "Executing SurrealDB query"); let record: Option = self .state .surrealdb_mem @@ -49,10 +54,12 @@ impl<'a> AuthRepository<'a> { } } + #[instrument(skip(self, email), err)] pub async fn query_get_stored_user( &self, email: String, ) -> Result { + info!(query = %format!("SELECT FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query"); let user_cache: Option = self .state .surrealdb_mem @@ -87,13 +94,29 @@ impl<'a> AuthRepository<'a> { Ok(UsersDetailQueryDto { id: Thing::from(("app_users".to_string(), email.clone())), fullname: "Cached User".to_string(), + legal_name: None, email: cache.email, avatar: None, phone_number: String::new(), + phone_for_verification: None, is_active: true, is_deleted: false, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, password: String::new(), role: role_detail_query_dto, created_at: String::new(), @@ -105,7 +128,9 @@ impl<'a> AuthRepository<'a> { } } + #[instrument(skip(self, email), err)] pub async fn query_delete_stored_user(&self, email: String) -> Result { + info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query"); let record: Option = self .state .surrealdb_mem @@ -117,13 +142,16 @@ impl<'a> AuthRepository<'a> { } } + #[instrument(skip(self, email), err)] pub async fn query_get_stored_otp(&self, email: String) -> Result { let table = ResourceEnum::OtpCache.to_string(); let key = (table.as_str(), email.as_str()); + info!(query = %format!("SELECT FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query"); let result: Option = self.state.surrealdb_mem.select(key).await?; match result { Some(data) => match Utc::now() > data.expires_at { true => { + info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query"); let _ = self .state .surrealdb_mem @@ -137,9 +165,11 @@ impl<'a> AuthRepository<'a> { } } + #[instrument(skip(self, email, otp), err)] pub async fn query_store_otp(&self, email: String, otp: u32) -> Result { let expires_at = Utc::now() + Duration::seconds(300); let table: String = ResourceEnum::OtpCache.to_string(); + info!(query = %format!("CREATE {}:{}", table, email), "Executing SurrealDB query"); let record: Option = self .state .surrealdb_mem @@ -152,7 +182,9 @@ impl<'a> AuthRepository<'a> { } } + #[instrument(skip(self, email), err)] pub async fn query_delete_stored_otp(&self, email: String) -> Result { + info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::OtpCache.to_string(), email), "Executing SurrealDB query"); let record: Option = self .state .surrealdb_mem diff --git a/imphnen-iam/src/v1/auth/auth_service.rs b/imphnen-iam/src/v1/auth/auth_service.rs index 9076d23..85a1fc5 100644 --- a/imphnen-iam/src/v1/auth/auth_service.rs +++ b/imphnen-iam/src/v1/auth/auth_service.rs @@ -1,38 +1,88 @@ +use std::pin::Pin; +use std::future::Future; use super::{ AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto, }; use crate::{ - AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository, + AppState, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository, UsersDetailItemDto, UsersRepository, UsersSchema, common_response, decode_refresh_token, encode_access_token, encode_refresh_token, - encode_reset_password_token, extract_email_token, generate_otp, get_iso_date, + encode_reset_password_token, extract_email_token_async, generate_otp, get_iso_date, hash_password, make_thing, send_email, success_response, validate_request, verify_password, }; use axum::{http::StatusCode, response::Response}; use surrealdb::Uuid; use tracing::error; +use tokio; + +pub trait AuthServiceTrait: Send + Sync + 'static { + fn mutation_login( + payload: AuthLoginRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_mentor_login( + payload: AuthLoginRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_register( + payload: AuthRegisterRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_resend_otp( + payload: AuthResendOtpRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_refresh_token( + payload: AuthRefreshTokenRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_forgot_password( + payload: AuthResendOtpRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_verify_email( + payload: AuthVerifyEmailRequestDto, + state: &AppState, + ) -> Pin + Send>>; + fn mutation_new_password( + payload: AuthNewPasswordRequestDto, + state: &AppState, + ) -> Pin + Send>>; +} + +#[derive(Clone)] // Added Clone derive pub struct AuthService; -impl AuthService { - pub async fn mutation_login( + +impl AuthServiceTrait for AuthService { + fn mutation_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); - match user_repo.query_user_by_email(payload.email.clone()).await { + let email = &payload.email; + let password = &payload.password; + + match user_repo.query_user_by_email(email.to_string()).await { Ok(user) => { - let is_password_correct = - verify_password(&payload.password, &user.password).unwrap_or(false); + let is_password_correct = tokio::task::spawn_blocking({ + let password = password.to_owned(); + let user_password = user.password.clone(); + move || verify_password(&password, &user_password).unwrap_or(false) + }).await.unwrap_or(false); if !is_password_correct { return common_response( @@ -48,12 +98,16 @@ impl AuthService { ); } - let access_token = match encode_access_token(payload.email.clone()) { + // Avoid unnecessary clone of user for caching if not needed + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.as_str()).map(str::to_owned).collect(); + let user_id = user.id.id.to_raw(); + + let access_token = match encode_access_token(email.to_string(), user_id.clone(), permissions.clone()) { Ok(token) => token, Err(_e) => { error!( "Failed to generate access token for {}: {}", - payload.email, _e + email, _e ); return common_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -62,12 +116,12 @@ impl AuthService { } }; - let refresh_token = match encode_refresh_token(payload.email.clone()) { + let refresh_token = match encode_refresh_token(email.to_string(), user_id, permissions) { Ok(token) => token, Err(_e) => { error!( "Failed to generate refresh token for {}: {}", - payload.email, _e + email, _e ); return common_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -86,6 +140,7 @@ impl AuthService { }, }; + // Only clone user if caching is required if let Err(err_store) = auth_repo.query_store_user(user.clone()).await { error!( "Failed to store user cache for {}: {}", @@ -102,23 +157,30 @@ impl AuthService { common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) } } + }) } - pub async fn mutation_mentor_login( + fn mutation_mentor_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); match user_repo.query_user_by_email(payload.email.clone()).await { Ok(user) => { - let is_password_correct = - verify_password(&payload.password, &user.password).unwrap_or(false); + let is_password_correct = tokio::task::spawn_blocking({ + let password = payload.password.clone(); + let user_password = user.password.clone(); + move || verify_password(&password, &user_password).unwrap_or(false) + }).await.unwrap_or(false); if !is_password_correct { return common_response( @@ -143,7 +205,8 @@ impl AuthService { ); } - let access_token = match encode_access_token(payload.email.clone()) { + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); + let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) { Ok(token) => token, Err(_e) => { error!( @@ -157,7 +220,8 @@ impl AuthService { } }; - let refresh_token = match encode_refresh_token(payload.email.clone()) { + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); + let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) { Ok(token) => token, Err(_e) => { error!( @@ -197,18 +261,22 @@ impl AuthService { common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) } } + }) } - pub async fn mutation_register( + fn mutation_register( payload: AuthRegisterRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); - let role_repo = RolesRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); + let role_repo = RolesRepository::new(&state); let role = match role_repo .query_role_by_name(RolesEnum::User.to_string()) .await @@ -285,7 +353,7 @@ impl AuthService { created_at: get_iso_date(), updated_at: get_iso_date(), role: role_thing, - is_active: true, + is_active: false, ..Default::default() }) .await @@ -296,16 +364,20 @@ impl AuthService { common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string()) } } + }) } - pub async fn mutation_resend_otp( + fn mutation_resend_otp( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); + let user_repo = UsersRepository::new(&state); if user_repo .query_user_by_email(payload.email.clone()) .await @@ -313,7 +385,7 @@ impl AuthService { { return common_response(StatusCode::BAD_REQUEST, "User not found"); } - let auth_repo = AuthRepository::new(state); + let auth_repo = AuthRepository::new(&state); let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await; let otp = generate_otp::OtpManager::generate_otp(); let message = format!("Your OTP code is {otp}"); @@ -333,34 +405,48 @@ impl AuthService { common_response(StatusCode::BAD_REQUEST, &err_store.to_string()) } } + }) } - pub async fn mutation_refresh_token( + fn mutation_refresh_token( payload: AuthRefreshTokenRequestDto, - ) -> Response { + state: &AppState, + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let email = match decode_refresh_token(&payload.refresh_token) { - Ok(token) => token.claims.sub, + + let user_repo = UsersRepository::new(&state); + let user = match decode_refresh_token(&payload.refresh_token) { + Ok(token_data) => { + match user_repo.query_user_by_email(token_data.claims.sub.clone()).await { + Ok(user) => user, + Err(_) => return common_response(StatusCode::UNAUTHORIZED, "User not found"), + } + }, Err(_e) => { return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token"); } }; - let access_token = match encode_access_token(email.clone()) { + + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); + let access_token = match encode_access_token(user.email.clone(), user.id.id.to_raw(), permissions.clone()) { Ok(token) => token, Err(_e) => { - error!("Failed to generate access token for {}: {}", email, _e); + error!("Failed to generate access token for {}: {}", user.email, _e); return common_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate access token", ); } }; - let refresh_token = match encode_refresh_token(email.clone()) { + let refresh_token = match encode_refresh_token(user.email.clone(), user.id.id.to_raw(), permissions) { Ok(token) => token, Err(_e) => { - error!("Failed to generate refresh token for {}: {}", email, _e); + error!("Failed to generate refresh token for {}: {}", user.email, _e); return common_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate refresh token", @@ -374,148 +460,145 @@ impl AuthService { }, }; success_response(response) + }) } - pub async fn mutation_forgot_password( + fn mutation_forgot_password( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response { - if let Err((status, message)) = validate_request(&payload) { - return common_response(status, &message); - } - let user_repo = UsersRepository::new(state); - let user_result = user_repo.query_user_by_email(payload.email.clone()).await; - let user = match user_result { - Ok(user) => user, - Err(err_find) if err_find.to_string().contains("User not found") => { - return common_response(StatusCode::BAD_REQUEST, "User not found"); - } - Err(err_other) => { - error!( - "Error finding user for forgot password {}: {}", - payload.email, err_other - ); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - &err_other.to_string(), - ); - } - }; - let token = match encode_reset_password_token(user.email.clone()) { - Ok(token) => token, - Err(_e) => { - error!( - "Failed to generate reset password token for {}: {}", - user.email, _e - ); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to generate access token", - ); - } - }; - let env = Env::new(); - let fe_url = env.fe_url; - let message = format!( - "You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}" - ); - match send_email(&payload.email, "Reset Password Request", &message) { - Ok(_) => common_response(StatusCode::OK, "Reset Password request send"), - Err(err_send) => { - error!( - "Failed to send reset password email to {}: {}", - payload.email, err_send - ); - common_response(StatusCode::BAD_REQUEST, &err_send.to_string()) - } - } + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + + tokio::spawn(async move { + let user_repo = UsersRepository::new(&state); + if let Ok(user) = user_repo.query_user_by_email(payload.email.clone()).await { + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); + let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) { + Ok(token) => token, + Err(_e) => { + error!("Failed to generate reset password token for {}: {}", user.email, _e); + return; + } + }; + + let env = &crate::enviroment::ENV; + let fe_url = env.fe_url.clone(); + let message = format!( + "You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}" + ); + + if let Err(err_send) = send_email(&payload.email, "Reset Password Request", &message) { + error!("Failed to send reset password email to {}: {}", payload.email, err_send); + } + } + }); + + common_response(StatusCode::OK, "If your email is registered, you will receive a password reset link.") + }) } - pub async fn mutation_verify_email( + fn mutation_verify_email( payload: AuthVerifyEmailRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); let email = payload.email.clone(); let user = match user_repo.query_user_by_email(email.clone()).await { - Ok(user) if !user.is_deleted => user, + Ok(user) => user, _ => { return common_response(StatusCode::NOT_FOUND, "User not found"); } }; + + if user.is_active { + return common_response(StatusCode::BAD_REQUEST, "User already active"); + } + let patch = UsersSchema { id: user.id.clone(), is_active: true, ..UsersSchema::from(user.clone()) }; + match auth_repo.query_get_stored_otp(email.clone()).await { - Ok(stored_otp) => match stored_otp == payload.otp { - true => match user_repo.query_update_user(patch).await { - Ok(_) => match auth_repo.query_delete_stored_otp(email.clone()).await { - Ok(_) => common_response(StatusCode::OK, "Email verified successfully"), - Err(e_del) => { - error!("Failed to delete OTP for {}: {}", email, e_del); - common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string()) + Ok(stored_otp) => { + if stored_otp != payload.otp { + // Delete OTP even if it doesn't match + let _ = auth_repo.query_delete_stored_otp(email.clone()).await; + return common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"); + } + + match user_repo.query_update_user(patch).await { + Ok(_) => { + match auth_repo.query_delete_stored_otp(email.clone()).await { + Ok(_) => common_response(StatusCode::OK, "Email verified successfully"), + Err(e_del) => { + error!("Failed to delete OTP for {}: {}", email, e_del); + common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string()) + } } }, - Err(err_update) => { - common_response(StatusCode::BAD_REQUEST, &err_update.to_string()) - } - }, - false => match auth_repo.query_delete_stored_otp(email.clone()).await { - Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"), - Err(e_del_mismatch) => common_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Failed to delete OTP: {e_del_mismatch}"), - ), - }, + Err(err_update) => common_response(StatusCode::BAD_REQUEST, &err_update.to_string()), + } }, Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()), } + }) } - pub async fn mutation_new_password( + fn mutation_new_password( payload: AuthNewPasswordRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let repo = UsersRepository::new(state); - let email = match extract_email_token(payload.token.clone()) { - Some(token) => token, + let repo = UsersRepository::new(&state); + let user_repo = UsersRepository::new(&state); + let email = match extract_email_token_async(payload.token.clone()).await { + Some(email) => email, None => { return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token"); } }; + let user = match user_repo.query_user_by_email(email).await { + Ok(user) => user, + Err(_) => return common_response(StatusCode::BAD_REQUEST, "User not found"), + }; let password = match hash_password(&payload.password) { Ok(p) => p, Err(_e) => { - error!("Failed to hash new password for {}: {}", email, _e); + error!("Failed to hash new password for {}: {}", user.email, _e); return common_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to hash password", ); } }; - let user = match repo.query_user_by_email(email.clone()).await { - Ok(user) if !user.is_deleted => user, - _ => { - return common_response(StatusCode::NOT_FOUND, "User not found"); - } - }; let patch = UsersSchema { id: user.id.clone(), password, - ..Default::default() + ..UsersSchema::from(user.clone()) }; match repo.query_update_user(patch).await { Ok(msg) => common_response(StatusCode::OK, &msg), Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()), } + }) } -} +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs new file mode 100644 index 0000000..af0522a --- /dev/null +++ b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs @@ -0,0 +1,96 @@ +use axum::{ + extract::{Query, State}, + response::Redirect, + routing::get, + Json, Router, Extension, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use std::sync::Arc; +use imphnen_libs::enviroment::ENV; // Import ENV + +use crate::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl}; +use imphnen_entities::error_dto::error::Error; +use crate::v1::auth::AuthLoginResponsetDto; +use crate::AppState; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GoogleAuthUrlResponse { + pub authorize_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoogleLoginRequest { + pub redirect_uri: Option, +} + +pub struct GoogleOauthController { + google_oauth_service: T, +} + +impl GoogleOauthController> { + pub fn new() -> Self { + let google_oauth_service = GoogleOauthServiceImpl::::with_services( + crate::v1::auth::auth_service::AuthService {}, + crate::v1::users::users_service::UsersService {}, + &ENV, // Pass a reference to the global ENV static + ); + Self::with_service(google_oauth_service) + } +} + +impl GoogleOauthController +where + T: GoogleOauthService + Clone + Send + Sync + 'static, +{ + pub fn with_service(google_oauth_service: T) -> Self { + Self { + google_oauth_service, + } + } + + pub fn get_routes(&self) -> Router { + Router::new() + .route( + "/login", + get( + move |State(controller): State>, Query(params): Query| async move { + controller.google_oauth_login(params).await + }, + ), + ) + .route( + "/callback", + get( + move |State(controller): State>, Extension(app_state): Extension, Query(auth_request): Query| async move { + let controller = Arc::clone(&controller); + controller.google_oauth_callback(auth_request, &app_state).await + }, + ), + ) + .with_state(Arc::new(self.clone())) + } + + pub async fn google_oauth_login(&self, params: GoogleLoginRequest) -> Result { + let (authorize_url, _csrf_state) = self.google_oauth_service.generate_auth_url(params.redirect_uri); + Ok(Redirect::to(authorize_url.as_str())) + } + + pub async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Result, Error> { + let (user, token) = self.google_oauth_service.google_oauth_callback(auth_request, app_state).await?; + let auth_response = AuthLoginResponsetDto { + user, + token, + }; + Ok(Json(auth_response)) + } +} + +impl Clone for GoogleOauthController +where + T: GoogleOauthService + Clone, +{ + fn clone(&self) -> Self { + Self::with_service(self.google_oauth_service.clone()) + } +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_dto.rs b/imphnen-iam/src/v1/auth/google/google_oauth_dto.rs new file mode 100644 index 0000000..ec8c4f9 --- /dev/null +++ b/imphnen-iam/src/v1/auth/google/google_oauth_dto.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoogleUser { + pub id: String, + pub email: String, + #[serde(default)] + pub verified_email: bool, + pub name: Option, + pub given_name: Option, + pub family_name: Option, + pub picture: Option, + pub locale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoogleTokenResponse { + pub access_token: String, + pub expires_in: u64, + pub refresh_token: Option, + pub scope: String, + pub token_type: String, + pub id_token: String, +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs new file mode 100644 index 0000000..94d5593 --- /dev/null +++ b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs @@ -0,0 +1,379 @@ +use std::pin::Pin; +use std::future::Future; +use anyhow::Result; + +use oauth2::{ + AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, + RedirectUrl, Scope, TokenUrl, +}; +use serde::{Deserialize, Serialize}; +use oauth2::url::Url; +use oauth2::TokenResponse; +use tracing::{info, error}; + +use imphnen_entities::error_dto::error::Error; +use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env, AppState}; +use imphnen_utils::{generate_oauth_csrf_token, validate_oauth_csrf_token, validate_csrf_token}; +use crate::v1::auth::TokenDto; +use crate::v1::auth::auth_service::AuthServiceTrait; +use crate::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto}; +use crate::v1::users::users_service::UsersServiceTrait; + +use super::google_oauth_dto::GoogleUser; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthRequest { + pub code: String, + pub state: String, + pub redirect_uri: Option, +} + +impl AuthRequest { + /// Validate the OAuth callback request + pub fn validate(&self) -> Result<(), Error> { + // Validate code parameter + if self.code.is_empty() || self.code.len() > 2048 { + return Err(Error::Validation("Invalid authorization code".to_string())); + } + + // Validate state parameter + if self.state.is_empty() || self.state.len() > 512 { + return Err(Error::Validation("Invalid state parameter".to_string())); + } + + // Basic format validation for authorization code + // OAuth 2.0 authorization codes can contain URL-safe characters including base64 characters + if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' || c == '/' || c == '+' || c == '=') { + return Err(Error::Validation("Authorization code contains invalid characters".to_string())); + } + + Ok(()) + } + + /// Validate CSRF state token with signature verification and extract PKCE verifier + pub fn validate_csrf_state_and_get_pkce_verifier(&self, secret: &str) -> Result { + // Maximum age of 30 minutes for OAuth flow (increased from 10) + const MAX_AGE_SECONDS: u64 = 300; // Changed from 30 minutes (1800s) to 5 minutes (300s) + + let pkce_verifier_secret = validate_oauth_csrf_token(&self.state, secret, MAX_AGE_SECONDS) + .map_err(|e| { + error!("OAuth CSRF validation failed: {:?}", e); + Error::Auth("Invalid or expired OAuth CSRF state token".to_string()) + })?; + + Ok(PkceCodeVerifier::new(pkce_verifier_secret)) + } + + /// Validate CSRF state token with signature verification (legacy method for backward compatibility) + pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> { + // Try OAuth CSRF validation first, if it fails, fall back to regular CSRF validation + match validate_oauth_csrf_token(&self.state, secret, 1800) { + Ok(_) => Ok(()), + Err(_) => { + // Fallback to regular CSRF validation for backward compatibility + validate_csrf_token(&self.state, secret, 600) + .map_err(|e| { + error!("CSRF validation failed: {:?}", e); + Error::Auth("Invalid or expired CSRF state token".to_string()) + }) + } + } + } +} + +use crate::{RolesRepository, RolesEnum}; + +/// Helper function to get default role ID for new OAuth users +async fn get_default_role_id(app_state: &AppState) -> Result { + let role_repo = RolesRepository::new(app_state); + match role_repo.query_role_by_name(RolesEnum::User.to_string()).await { + Ok(role) => { + info!("Using default User role ID: {}", role.id); + Ok(role.id) + }, + Err(e) => { + error!("Failed to retrieve User role: {:?}", e); + Err(Error::Anyhow(anyhow::Error::msg("Failed to get default role ID".to_string()))) + } + } +} + + +pub trait GoogleOauthService: Send + Sync + 'static { + // Removed new() from trait + fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self; + fn generate_auth_url(&self, custom_redirect_uri: Option) -> (Url, CsrfToken); + fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin> + Send + '_>>; // Changed return type +} + +#[derive(Clone)] +pub struct GoogleOauthServiceImpl { + users_service: U, + env: &'static Env, + _auth_service: A, +} + +impl GoogleOauthServiceImpl { + /// Generate Google OAuth authorization URL + pub fn get_auth_url(&self, custom_redirect_uri: Option) -> String { + let (auth_url, _csrf_token) = self.generate_auth_url(custom_redirect_uri); + auth_url.to_string() + } +} + + + +impl GoogleOauthService for GoogleOauthServiceImpl +where + A: AuthServiceTrait + Send + Sync + 'static, + U: UsersServiceTrait + Send + Sync + 'static, +{ + fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self { + Self { + _auth_service: auth_service, + users_service, + env, + } + } + + + fn generate_auth_url(&self, custom_redirect_uri: Option) -> (Url, CsrfToken) { + let google_client_id = ClientId::new(self.env.google_client_id.clone()); + let google_client_secret = ClientSecret::new(self.env.google_client_secret.clone()); + 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://oauth2.googleapis.com/token".to_string()) + .expect("Invalid token endpoint URL"); + let redirect_uri = custom_redirect_uri.unwrap_or_else(|| self.env.google_redirect_url.clone()); + let client = oauth2::basic::BasicClient::new(google_client_id) + .set_client_secret(google_client_secret) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri( + RedirectUrl::new(redirect_uri.clone()) + .expect("Invalid redirect URL"), + ); + info!("OAuth client configured with redirect URI: {}", redirect_uri); + let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + info!("Generated PKCE Code Challenge: {}", pkce_code_challenge.as_str()); + info!("Generated PKCE Code Verifier: {}", pkce_code_verifier.secret()); + + // Generate a signed CSRF token with PKCE verifier for stateless validation + let csrf_token_str = generate_oauth_csrf_token(&self.env.access_token_secret, pkce_code_verifier.secret()) + .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails + + + + let (auth_url, csrf_token) = client + .authorize_url(|| CsrfToken::new(csrf_token_str.clone())) + .add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.email".to_string())) + .add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.profile".to_string())) + .set_pkce_challenge(pkce_code_challenge) + .url(); + (auth_url, csrf_token) + } + + fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin> + Send + '_>> { + let self_clone = self; // Use reference instead of clone + let app_state = app_state.to_owned(); + Box::pin(async move { + // Validate input parameters first + info!("Received OAuth callback request with state: {}", auth_request.state); + auth_request.validate()?; + + // CRITICAL: Validate CSRF state token and extract PKCE verifier + let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self_clone.env.access_token_secret)?; + + info!("Starting Google OAuth callback process"); + info!("Redirect URI used: {:?}", auth_request.redirect_uri); + info!("PKCE verifier extracted: {}", pkce_verifier.secret()); + info!("PKCE verifier extracted: {}", pkce_verifier.secret()); + + // Use the SAME redirect URI that was used for auth URL generation + // This is crucial for OAuth security and consistency + let google_client_id = ClientId::new(self_clone.env.google_client_id.clone()); + let google_client_secret = ClientSecret::new(self_clone.env.google_client_secret.clone()); + 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://oauth2.googleapis.com/token".to_string()) + .expect("Invalid token endpoint URL"); + let redirect_uri = auth_request.redirect_uri.clone().unwrap_or_else(|| self_clone.env.google_redirect_url.clone()); + let client = oauth2::basic::BasicClient::new(google_client_id) + .set_client_secret(google_client_secret) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri( + RedirectUrl::new(redirect_uri.clone()) + .expect("Invalid redirect URL"), + ); + info!("OAuth client configured with redirect URI: {}", redirect_uri); + + // Debug the OAuth client configuration + let effective_redirect_uri = auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url); + info!("Effective redirect URI for OAuth client: {}", effective_redirect_uri); + info!("Google Client ID: {}", self_clone.env.google_client_id); + + info!("Attempting to exchange authorization code with Google"); + info!("Using PKCE verifier for secure exchange"); + info!("Authorization code length: {}", auth_request.code.len()); + + let token_response = client + .exchange_code(oauth2::AuthorizationCode::new(auth_request.code.clone())) + .set_pkce_verifier(pkce_verifier) + .request_async(&reqwest::Client::new()) + .await + .map_err(|e| { + error!("Failed to exchange OAuth code with Google: {}", e); + error!("OAuth code was: {}", auth_request.code); + error!("Redirect URI was: {:?}", auth_request.redirect_uri); + + // Debug OAuth client configuration + error!("Google Client ID: {}", self_clone.env.google_client_id); + error!("OAuth client redirect URI configured: {}", + auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url)); + + // Try to extract more details from the error + match &e { + oauth2::RequestTokenError::ServerResponse(response) => { + error!("Google OAuth Server Response Error: {:?}", response); + }, + oauth2::RequestTokenError::Request(req_err) => { + error!("Google OAuth Request Error: {:?}", req_err); + }, + oauth2::RequestTokenError::Parse(parse_err, response_body) => { + error!("Google OAuth Parse Error: {:?}", parse_err); + error!("Response body: {:?}", response_body); + }, + oauth2::RequestTokenError::Other(other) => { + error!("Google OAuth Other Error: {:?}", other); + }, + } + + Error::Auth("Authentication error: Failed to exchange authorization code".to_string()) + })?; + + info!("Successfully exchanged authorization code for access token"); + + // Extract access token from Google's response + let access_token = token_response.access_token().secret(); + info!("Obtained access token from Google, fetching user info..."); + + let client = reqwest::Client::new(); + let user_info_url = "https://www.googleapis.com/oauth2/v2/userinfo"; + let google_user: GoogleUser = client + .get(user_info_url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| { + error!("Failed to fetch user info from Google: {}", e); + Error::Auth("Failed to fetch user information".to_string()) + })? + .json() + .await + .map_err(|e| { + error!("Failed to parse user info from Google: {}", e); + Error::Auth("Failed to parse user information".to_string()) + })?; + + info!("Successfully retrieved user info for email: {}", google_user.email); + info!("Google user picture URL: {:?}", google_user.picture); + info!("Google user data: name={:?}, given_name={:?}, family_name={:?}, picture={:?}", + google_user.name, google_user.given_name, google_user.family_name, google_user.picture); + + let user = self_clone.users_service.get_user_by_email(&google_user.email, &app_state).await?; + + let user = match user { + Some(mut user) => { + info!("Existing user found for email: {}", google_user.email); + + // Update avatar if user doesn't have one and Google provides one + if user.avatar.is_none() && google_user.picture.is_some() { + info!("Updating avatar for existing user: {}", google_user.email); + match U::update_user_avatar(&google_user.email, google_user.picture.clone(), &app_state).await { + Ok(_) => { + info!("Successfully updated avatar for user: {}", google_user.email); + user.avatar = google_user.picture.clone(); + }, + Err(e) => { + error!("Failed to update avatar for user {}: {:?}", google_user.email, e); + } + } + } + + user + }, + None => { + info!("Creating new user for email: {}", google_user.email); + + // Get default role ID using robust lookup + let default_role_id = get_default_role_id(&app_state).await + .map_err(|e| { + error!("Failed to get default role ID: {:?}", e); + Error::Anyhow(anyhow::Error::msg("Failed to get default role ID for new user".to_string())) + })?; + + let new_user = UsersCreateRequestDto { + email: google_user.email.clone(), + password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder + fullname: google_user.name.clone().unwrap_or_else(|| { + // Fallback: use given_name + family_name if available, otherwise use email prefix + match (&google_user.given_name, &google_user.family_name) { + (Some(given), Some(family)) => format!("{} {}", given, family), + (Some(given), None) => given.clone(), + (None, Some(family)) => family.clone(), + (None, None) => { + // Extract email prefix as last resort + google_user.email.split('@').next().unwrap_or("User").to_string() + } + } + }), + phone_number: "".to_string(), // Will be updated by user later + is_active: true, + role_id: default_role_id, + avatar: google_user.picture.clone(), // Set avatar from Google user picture + }; + + self_clone.users_service.create_user_by_dto(new_user, &app_state).await? + } + }; + + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); +let access_token = encode_access_token(user.email.clone(), user.id.clone(), permissions.clone()) + .map_err(|e| { + error!("Failed to generate access token for {}: {:?}", user.email, e); + Error::Auth("Failed to generate access token".to_string()) + })?; + + let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); +let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), permissions) + .map_err(|e| { + error!("Failed to generate refresh token for {}: {:?}", user.email, e); + Error::Auth("Failed to generate refresh token".to_string()) + })?; + + let token_dto = TokenDto { + access_token, + refresh_token, + }; + + // Cache the user in auth repository for subsequent requests + let auth_repo = crate::v1::auth::AuthRepository::new(&app_state); + let user_query_dto: crate::v1::users::users_dto::UsersDetailQueryDto = (&user).into(); + if let Err(err_store) = auth_repo.query_store_user(user_query_dto).await { + error!( + "Failed to store user cache for {}: {}", + user.email, err_store + ); + // Don't fail the login, just log the error + error!("Google OAuth login succeeded but caching failed for user: {}", user.email); + } else { + + info!("Successfully cached user {} after Google OAuth login", user.email); + } + + info!("Successfully completed Google OAuth for user: {}", user.email); + Ok((user, token_dto)) +}) + } +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/mod.rs b/imphnen-iam/src/v1/auth/google/mod.rs new file mode 100644 index 0000000..8b71daa --- /dev/null +++ b/imphnen-iam/src/v1/auth/google/mod.rs @@ -0,0 +1,3 @@ +pub mod google_oauth_controller; +pub mod google_oauth_dto; +pub mod google_oauth_service; \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/mod.rs b/imphnen-iam/src/v1/auth/mod.rs index a548a80..7ed810d 100644 --- a/imphnen-iam/src/v1/auth/mod.rs +++ b/imphnen-iam/src/v1/auth/mod.rs @@ -5,6 +5,7 @@ pub mod auth_dto; pub mod auth_repository; pub mod auth_schema; pub mod auth_service; +pub mod google; pub use auth_dto::*; pub use auth_repository::*; @@ -13,6 +14,7 @@ pub use auth_service::*; pub fn auth_router() -> Router { Router::new() + .nest("/google", google::google_oauth_controller::GoogleOauthController::new().get_routes()) .route("/forgot", post(auth_controller::post_forgot_password)) .route("/login", post(auth_controller::post_login)) .route("/login-mentor", post(auth_controller::post_login_mentor)) diff --git a/imphnen-iam/src/v1/permissions/permissions_controller.rs b/imphnen-iam/src/v1/permissions/permissions_controller.rs index 60708ec..adb32ed 100644 --- a/imphnen-iam/src/v1/permissions/permissions_controller.rs +++ b/imphnen-iam/src/v1/permissions/permissions_controller.rs @@ -8,7 +8,7 @@ use crate::{ AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, v1::{ - permissions_dto::{PermissionsItemDto, PermissionsRequestDto}, + permissions_dto::{PermissionsItemDto, PermissionsRequestDto, PermissionsUpdateRequestDto}, permissions_service::PermissionsService, }, }; @@ -41,13 +41,13 @@ pub async fn get_permission_list( Query(meta): Query, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadListPermissions], ) .await { - Ok(_) => PermissionsService::get_permission_list(&state, meta).await, + Ok((_claims, state)) => PermissionsService::get_permission_list(&state, meta).await, Err(response) => response, } } @@ -70,13 +70,13 @@ pub async fn get_permission_by_id( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadDetailPermissions], ) .await { - Ok(_) => PermissionsService::get_permission_by_id(&state, id).await, + Ok((_claims, state)) => PermissionsService::get_permission_by_id(&state, id).await, Err(response) => response, } } @@ -99,13 +99,13 @@ pub async fn post_create_permission( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreatePermissions], ) .await { - Ok(_) => PermissionsService::create_role(&state, payload).await, + Ok((_claims, state)) => PermissionsService::create_role(&state, payload).await, Err(response) => response, } } @@ -116,7 +116,7 @@ pub async fn post_create_permission( ("Bearer" = []) ), path = "/v1/permissions/update/{id}", - request_body = PermissionsRequestDto, + request_body = PermissionsUpdateRequestDto, responses( (status = 200, description = "Update permission", body = MessageResponseDto) ), @@ -126,16 +126,16 @@ pub async fn put_update_permission( headers: axum::http::HeaderMap, Extension(state): Extension, Path(id): Path, - Json(payload): Json, + Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::UpdatePermissions], ) .await { - Ok(_) => PermissionsService::update_permission(&state, payload, id).await, + Ok((_claims, state)) => PermissionsService::update_permission(&state, payload, id).await, Err(response) => response, } } @@ -157,13 +157,13 @@ pub async fn delete_permission( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::DeletePermissions], ) .await { - Ok(_) => PermissionsService::delete_permission(&state, id).await, + Ok((_claims, state)) => PermissionsService::delete_permission(&state, id).await, Err(response) => response, } } diff --git a/imphnen-iam/src/v1/permissions/permissions_dto.rs b/imphnen-iam/src/v1/permissions/permissions_dto.rs index 19aaf3b..84cae19 100644 --- a/imphnen-iam/src/v1/permissions/permissions_dto.rs +++ b/imphnen-iam/src/v1/permissions/permissions_dto.rs @@ -9,6 +9,13 @@ pub struct PermissionsRequestDto { pub name: String, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct PermissionsUpdateRequestDto { + #[validate(length(min = 1, message = "Permission name must not be empty"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct PermissionsItemDto { pub id: String, diff --git a/imphnen-iam/src/v1/permissions/permissions_guard.rs b/imphnen-iam/src/v1/permissions/permissions_guard.rs index 125721e..1bd231f 100644 --- a/imphnen-iam/src/v1/permissions/permissions_guard.rs +++ b/imphnen-iam/src/v1/permissions/permissions_guard.rs @@ -1,38 +1,41 @@ use super::PermissionsEnum; -use crate::{AppState, AuthRepository, common_response, extract_email}; +use crate::{AppState, common_response, decode_access_token}; use axum::{ http::{HeaderMap, StatusCode}, - response::Response, + response::Response, Extension, }; +use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt}; +// Removed imphnen_utils::make_thing as it's no longer needed here pub async fn permissions_guard( - headers: &HeaderMap, - state: AppState, + headers: HeaderMap, + Extension(state): Extension, required_permissions: Vec, -) -> Result<(), Response> { - let auth_repo = AuthRepository::new(&state); - let email = extract_email(headers).ok_or_else(|| { - common_response( - StatusCode::UNAUTHORIZED, - "Invalid or missing authorization token", - ) - })?; - let raw_user = auth_repo - .query_get_stored_user(email.clone()) - .await +) -> Result<(imphnen_libs::jsonwebtoken::Claims, AppState), Response> { + let auth_header = headers + .typed_get::>() + .ok_or_else(|| { + common_response( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + ) + })?; + + let token = auth_header.token(); + + let claims = decode_access_token(token) .map_err(|_| { common_response( StatusCode::UNAUTHORIZED, - "User session expired or not found", + "Invalid or expired token", ) - })?; - let role = raw_user.role; - let role_permissions: Vec = - role.permissions.into_iter().map(|perm| perm.name).collect(); + })? + .claims; + // Use permissions from JWT for the check for required in &required_permissions { let required_str = required.to_string(); - if !role_permissions.contains(&required_str) { + if !claims.permissions.contains(&required_str) { eprintln!(" MISSING REQUIRED PERMISSION: {required_str}"); return Err(common_response( StatusCode::FORBIDDEN, @@ -40,5 +43,6 @@ pub async fn permissions_guard( )); } } - Ok(()) + + Ok((claims, state)) } diff --git a/imphnen-iam/src/v1/permissions/permissions_repository.rs b/imphnen-iam/src/v1/permissions/permissions_repository.rs index 0f77ad2..634e02a 100644 --- a/imphnen-iam/src/v1/permissions/permissions_repository.rs +++ b/imphnen-iam/src/v1/permissions/permissions_repository.rs @@ -7,6 +7,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, extract_id}; use serde_json; use std::time::Instant; use tracing::instrument; +use tracing::info; pub struct PermissionsRepository<'a> { state: &'a AppState, @@ -23,6 +24,7 @@ impl<'a> PermissionsRepository<'a> { meta: MetaRequestDto, ) -> Result>> { let now = Instant::now(); + info!("Executing SurrealDB query: QueryListBuilder for Permissions with meta: {:?}", meta); let raw_result: ResponseListSuccessDto> = QueryListBuilder::new( &self.state.surrealdb_ws, @@ -61,6 +63,7 @@ impl<'a> PermissionsRepository<'a> { ) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + info!(id = %id, "Executing SurrealDB select for Permissions"); let result: Option = db .select((ResourceEnum::Permissions.to_string(), id.clone())) .await?; @@ -82,6 +85,7 @@ impl<'a> PermissionsRepository<'a> { id: String, ) -> Result { let now = Instant::now(); + info!(id = %id, "Executing transformed_query_permission_by_id (delegates to query_permission_by_id)"); let raw_result = self.query_permission_by_id(id.clone()).await?; let elapsed = now.elapsed(); if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) @@ -109,6 +113,7 @@ impl<'a> PermissionsRepository<'a> { .with_where("name", Some(name.clone())) .with_select_fields(vec!["*"]); let sql = builder.build(); + info!(query = %sql, "Executing SurrealDB query"); let result: Option = builder.apply_bindings(db.query(sql)).await?.take(0)?; let elapsed = now.elapsed(); @@ -130,6 +135,7 @@ impl<'a> PermissionsRepository<'a> { ) -> Result { let now = Instant::now(); let db = &self.state.surrealdb_ws; + info!("Executing SurrealDB create for Permissions with data: {:?}", data); let record: Option = db .create(ResourceEnum::Permissions.to_string()) .content(data) @@ -162,6 +168,7 @@ impl<'a> PermissionsRepository<'a> { created_at: existing.created_at, ..data.clone() }; + info!(record_key = ?record_key, "Executing SurrealDB update for Permissions"); let record: Option = db.update(record_key).merge(merged).await?; let elapsed = now.elapsed(); @@ -188,6 +195,7 @@ impl<'a> PermissionsRepository<'a> { bail!("Permission already deleted"); } let record_key = get_id(&permission.id)?; + info!(record_key = ?record_key, "Executing SurrealDB soft delete for Permissions"); let record: Option = db .update(record_key) .merge(serde_json::json!({ "is_deleted": true })) diff --git a/imphnen-iam/src/v1/permissions/permissions_schema.rs b/imphnen-iam/src/v1/permissions/permissions_schema.rs index ef86d95..53a76b9 100644 --- a/imphnen-iam/src/v1/permissions/permissions_schema.rs +++ b/imphnen-iam/src/v1/permissions/permissions_schema.rs @@ -2,7 +2,7 @@ use crate::{ResourceEnum, make_thing}; use serde::{Deserialize, Serialize}; use surrealdb::{Uuid, sql::Thing}; -use super::PermissionsItemDto; +use super::{PermissionsItemDto, PermissionsQueryDto}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PermissionsSchema { @@ -37,4 +37,14 @@ impl PermissionsSchema { updated_at: self.updated_at.clone(), } } + + pub fn from(dto: PermissionsQueryDto) -> Self { + Self { + id: dto.id, + name: dto.name, + is_deleted: false, + created_at: dto.created_at, + updated_at: dto.updated_at, + } + } } diff --git a/imphnen-iam/src/v1/permissions/permissions_service.rs b/imphnen-iam/src/v1/permissions/permissions_service.rs index 7d8f730..094cd40 100644 --- a/imphnen-iam/src/v1/permissions/permissions_service.rs +++ b/imphnen-iam/src/v1/permissions/permissions_service.rs @@ -5,8 +5,9 @@ use crate::{ }; use axum::http::StatusCode; use axum::response::Response; +use imphnen_utils::get_iso_date; -use super::PermissionsRequestDto; +use super::{PermissionsRequestDto, PermissionsUpdateRequestDto}; pub struct PermissionsService; @@ -70,21 +71,31 @@ impl PermissionsService { pub async fn update_permission( state: &AppState, - payload: PermissionsRequestDto, + payload: PermissionsUpdateRequestDto, id: String, ) -> Response { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } let repo = PermissionsRepository::new(state); - match repo - .query_update_permission(PermissionsSchema { - id: make_thing(&ResourceEnum::Permissions.to_string(), &id), - name: payload.name, - ..Default::default() - }) - .await - { + + // Get current permission data first + let thing_id = make_thing(&ResourceEnum::Permissions.to_string(), &id); + let current_permission = match repo.query_permission_by_id(id.clone()).await { + Ok(permission) => permission, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Permission not found"), + }; + + let mut updated_permission = current_permission; + updated_permission.id = thing_id; + updated_permission.updated_at = Some(get_iso_date()); + + // Only update fields that are provided + if let Some(name) = payload.name { + updated_permission.name = name; + } + + match repo.query_update_permission(updated_permission).await { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => { if e.to_string().contains("not found") { diff --git a/imphnen-iam/src/v1/roles/roles_controller.rs b/imphnen-iam/src/v1/roles/roles_controller.rs index e9333eb..4a1813e 100644 --- a/imphnen-iam/src/v1/roles/roles_controller.rs +++ b/imphnen-iam/src/v1/roles/roles_controller.rs @@ -39,13 +39,13 @@ pub async fn get_role_list( Query(meta): Query, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadListRoles], ) .await { - Ok(_) => RolesService::get_role_list(&state, meta).await, + Ok((_claims, state)) => RolesService::get_role_list(&state, meta).await, Err(response) => response, } } @@ -68,13 +68,13 @@ pub async fn get_role_by_id( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadDetailRoles], ) .await { - Ok(_) => RolesService::get_role_by_id(&state, id).await, + Ok((_claims, state)) => RolesService::get_role_by_id(&state, id).await, Err(response) => response, } } @@ -97,13 +97,13 @@ pub async fn post_create_role( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreateRoles], ) .await { - Ok(_) => RolesService::create_role(&state, payload).await, + Ok((_claims, state)) => RolesService::create_role(&state, payload).await, Err(response) => response, } } @@ -127,13 +127,13 @@ pub async fn put_update_role( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::UpdateRoles], ) .await { - Ok(_) => RolesService::update_role(&state, id, payload).await, + Ok((_claims, state)) => RolesService::update_role(&state, id, payload).await, Err(response) => response, } } @@ -155,13 +155,13 @@ pub async fn delete_role( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::DeleteRoles], ) .await { - Ok(_) => RolesService::delete_role(&state, id).await, + Ok((_claims, state)) => RolesService::delete_role(&state, id).await, Err(response) => response, } } diff --git a/imphnen-iam/src/v1/roles/roles_dto.rs b/imphnen-iam/src/v1/roles/roles_dto.rs index 5cc289f..c26cc8f 100644 --- a/imphnen-iam/src/v1/roles/roles_dto.rs +++ b/imphnen-iam/src/v1/roles/roles_dto.rs @@ -28,7 +28,7 @@ pub struct RolesListItemDto { pub updated_at: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] // Added Default derive pub struct RolesDetailItemDto { pub id: String, pub name: String, diff --git a/imphnen-iam/src/v1/users/mod.rs b/imphnen-iam/src/v1/users/mod.rs index d2001d6..fd945b4 100644 --- a/imphnen-iam/src/v1/users/mod.rs +++ b/imphnen-iam/src/v1/users/mod.rs @@ -25,4 +25,5 @@ pub fn users_router() -> Router { .route("/detail/{id}", get(get_user_by_id)) .route("/update/{id}", put(put_update_user)) .route("/update/me", put(put_update_user_me)) + .route("/upload", post(upload_file)) } diff --git a/imphnen-iam/src/v1/users/users_controller.rs b/imphnen-iam/src/v1/users/users_controller.rs index 442a78c..f224965 100644 --- a/imphnen-iam/src/v1/users/users_controller.rs +++ b/imphnen-iam/src/v1/users/users_controller.rs @@ -1,16 +1,27 @@ -use crate::{AppState, MetaRequestDto, v1::users_service::UsersService}; +use crate::{AppState, MetaRequestDto}; use crate::{ MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, UsersCreateRequestDto, UsersDetailItemDto, permissions_guard, }; -use axum::extract::{Path, Query}; +use axum::extract::{Path, Multipart}; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::{Extension, Json}; +use utoipa::ToSchema; +use serde::{Deserialize, Serialize}; use super::{ UsersActiveInactiveRequestDto, UsersListItemDto, UsersUpdateRequestDto, }; +use crate::v1::users::users_service::{UsersServiceTrait, UsersService}; + +#[derive(Serialize, Deserialize, ToSchema)] +#[schema(description = "File upload form data for multipart/form-data")] +pub struct FileUploadSchema { + /// Binary file data to upload + #[schema(format = "binary")] + pub file: String, +} #[utoipa::path( get, @@ -35,33 +46,33 @@ use super::{ pub async fn get_user_list( headers: HeaderMap, Extension(state): Extension, - Query(meta): Query, + axum::extract::Query(meta): axum::extract::Query, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadListUsers], ) .await { - Ok(_) => UsersService::get_user_list(&state, meta).await, + Ok((_claims, state)) => UsersService::get_user_list(&state, meta).await, Err(response) => response, } } #[utoipa::path( - get, - security( - ("Bearer" = []) - ), - path = "/v1/users/detail/{id}", - params( - ("id" = String, Path, description = "User ID") - ), - responses( - (status = 200, description = "Get user by ID", body = ResponseSuccessDto) - ), - tag = "Users" +get, +security( + ("Bearer" = []) + ), +path = "/v1/users/detail/{id}", +params( + ("id" = String, Path, description = "User ID") +), +responses( + (status = 200, description = "Get user by ID", body = ResponseSuccessDto) +), +tag = "Users" )] pub async fn get_user_by_id( headers: HeaderMap, @@ -69,34 +80,34 @@ pub async fn get_user_by_id( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ReadDetailUsers], ) .await { - Ok(_) => UsersService::get_user_by_id(&state, id).await, + Ok((_claims, state)) => UsersService::get_user_by_id(&state, id).await, Err(response) => response, } } #[utoipa::path( - get, - security( - ("Bearer" = []) - ), - path = "/v1/users/me", - responses( - (status = 200, description = "Get user by ID", body = ResponseSuccessDto) - ), - tag = "Users" +get, +security( + ("Bearer" = []) + ), +path = "/v1/users/me", +responses( + (status = 200, description = "Get user by ID", body = ResponseSuccessDto) +), +tag = "Users" )] pub async fn get_user_me( - Extension(state): Extension, headers: HeaderMap, + Extension(state): Extension, ) -> impl IntoResponse { - match permissions_guard(&headers, state.clone(), vec![]).await { - Ok(_) => UsersService::get_user_me(headers, &state).await, + match permissions_guard(headers, Extension(state), vec![]).await { + Ok((claims, state)) => UsersService::get_user_me(claims, &state).await, Err(response) => response, } } @@ -109,7 +120,7 @@ pub async fn get_user_me( path = "/v1/users/create", request_body = UsersCreateRequestDto, responses( - (status = 201, description = "Create new user", body = MessageResponseDto) + (status = 200, description = "Create new user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -119,13 +130,13 @@ pub async fn post_create_user( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::CreateUsers], ) .await { - Ok(_) => UsersService::create_user(&state, payload).await, + Ok((_claims, state)) => UsersService::create_user(&state, payload).await, Err(response) => response, } } @@ -136,9 +147,12 @@ pub async fn post_create_user( ("Bearer" = []) ), path = "/v1/users/update/{id}", + params( + ("id" = String, Path, description = "User ID") + ), request_body = UsersUpdateRequestDto, responses( - (status = 200, description = "Update user", body = MessageResponseDto) + (status = 200, description = "Update user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -149,13 +163,13 @@ pub async fn put_update_user( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::UpdateUsers], ) .await { - Ok(_) => UsersService::update_user(&state, id, payload).await, + Ok((_claims, state)) => UsersService::update_user(&state, id, payload).await, Err(response) => response, } } @@ -168,7 +182,7 @@ pub async fn put_update_user( path = "/v1/users/update/me", request_body = UsersUpdateRequestDto, responses( - (status = 200, description = "Update user me", body = MessageResponseDto) + (status = 200, description = "Update current user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -177,8 +191,8 @@ pub async fn put_update_user_me( Extension(state): Extension, Json(payload): Json, ) -> impl IntoResponse { - match permissions_guard(&headers, state.clone(), vec![]).await { - Ok(_) => UsersService::update_user_me(&state, headers, payload).await, + match permissions_guard(headers.clone(), Extension(state), vec![]).await { + Ok((claims, state)) => UsersService::update_user_me(claims, &state, payload).await, Err(response) => response, } } @@ -189,9 +203,12 @@ pub async fn put_update_user_me( ("Bearer" = []) ), path = "/v1/users/activate/{id}", + params( + ("id" = String, Path, description = "User ID") + ), request_body = UsersActiveInactiveRequestDto, responses( - (status = 200, description = "Set user active/inactive", body = MessageResponseDto) + (status = 200, description = "Set user active status", body = MessageResponseDto) ), tag = "Users" )] @@ -202,13 +219,13 @@ pub async fn patch_user_active_status( Json(payload): Json, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::ActivateUsers], ) .await { - Ok(_) => UsersService::set_user_active_status(&state, id, payload).await, + Ok((_claims, state)) => UsersService::set_user_active_status(&state, id, payload).await, Err(response) => response, } } @@ -230,13 +247,56 @@ pub async fn delete_user( Path(id): Path, ) -> impl IntoResponse { match permissions_guard( - &headers, - state.clone(), + headers, + Extension(state), vec![PermissionsEnum::DeleteUsers], ) .await { - Ok(_) => UsersService::delete_user(&state, id).await, + Ok((_claims, state)) => UsersService::delete_user(&state, id).await, + Err(response) => response, + } +} + +#[utoipa::path( + post, + security( + ("Bearer" = []) + ), + path = "/v1/users/upload", + request_body( + content = FileUploadSchema, + description = "Upload file with multipart form data. Only 'file' field is required - file type will be detected automatically from the uploaded file.", + content_type = "multipart/form-data" + ), + responses( + (status = 200, description = "Upload file successfully", body = ResponseSuccessDto), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + tag = "Users" +)] +pub async fn upload_file( + headers: HeaderMap, + Extension(state): Extension, + multipart: Multipart, +) -> impl IntoResponse { + // Check authentication first + match permissions_guard( + headers, + Extension(state), + vec![], // No specific permission needed, just authentication + ) + .await + { + Ok((claims, state)) => { + // Extract user ID from user data + let user_id = claims.user_id.clone(); // Use claims.user_id directly + + // Process upload - don't use match here since it returns Response directly + UsersService::upload_file(&state, user_id, multipart).await + }, Err(response) => response, } } diff --git a/imphnen-iam/src/v1/users/users_dto.rs b/imphnen-iam/src/v1/users/users_dto.rs index afd532f..d00d7ce 100644 --- a/imphnen-iam/src/v1/users/users_dto.rs +++ b/imphnen-iam/src/v1/users/users_dto.rs @@ -4,12 +4,31 @@ use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; use utoipa::ToSchema; use validator::Validate; +use crate::UsersSchema; // Import UsersSchema lazy_static! { static ref PASSWORD_REGEX: regex::Regex = regex::Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ExperienceDto { + pub id: String, + pub company: String, + pub position: String, + pub duration: String, + pub period: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct EducationDto { + pub id: String, + pub institution: String, + pub degree: String, + pub field: String, + pub period: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersActiveInactiveRequestDto { pub is_active: bool, @@ -49,6 +68,7 @@ pub struct UsersCreateRequestDto { pub phone_number: String, pub is_active: bool, pub role_id: String, + pub avatar: Option, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] @@ -57,60 +77,173 @@ pub struct UsersUpdateRequestDto { length(min = 1, message = "Email cannot be empty"), email(message = "Email not valid") )] - pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, #[validate(length( min = 8, message = "Password must have at least 8 characters" ))] - pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, #[validate(length(min = 2, message = "Fullname at least have 2 character"))] - pub fullname: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fullname: Option, + #[validate(length(min = 2, message = "Legal name at least have 2 character"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, #[validate(length( min = 10, message = "Phone number at least have 10 character" ))] - pub phone_number: String, - pub is_active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_active: Option, #[validate(length(min = 1, message = "Gender is required"))] + #[serde(skip_serializing_if = "Option::is_none")] pub gender: Option, #[validate(length(min = 1, message = "Birthdate is required"))] + #[serde(skip_serializing_if = "Option::is_none")] pub birthdate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domicile: Option, + #[validate(length(min = 50, message = "Bio must be at least 50 characters"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_education: Option, + #[validate(url(message = "Invalid LinkedIn URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub linkedin_url: Option, + #[validate(url(message = "Invalid GitHub URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub github_url: Option, + #[validate(url(message = "Invalid CV URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub cv_url: Option, + #[validate(url(message = "Invalid portfolio URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub portfolio_url: Option, + #[validate(url(message = "Invalid website URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, + #[validate(url(message = "Invalid Twitter URL"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub twitter_url: Option, #[validate(length(min = 1, message = "Avatar is required"))] + #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, - pub role_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub experience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub education: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub career_status: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct UsersDetailItemDto { pub id: String, pub role: RolesDetailItemDto, pub fullname: String, + pub legal_name: Option, pub email: String, pub avatar: Option, pub phone_number: String, + pub phone_for_verification: Option, pub is_active: bool, pub gender: Option, pub birthdate: Option, + pub domicile: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub website_url: Option, + pub twitter_url: Option, + pub location: Option, + pub skills: Option>, + pub experience: Option>, + pub education: Option>, + pub career_status: Option, pub created_at: String, pub updated_at: String, } impl UsersDetailItemDto { - pub fn from(dto: &UsersDetailQueryDto) -> Self { + pub fn from(dto: &UsersDetailQueryDto) -> Self { // Reverted to taking a reference Self { id: dto.id.id.to_raw().clone(), role: RolesDetailItemDto::from(&dto.role), fullname: dto.fullname.clone(), + legal_name: dto.legal_name.clone(), email: dto.email.clone(), avatar: dto.avatar.clone(), - phone_number: dto.phone_number.clone(), + phone_number: dto.phone_number.clone(), // Corrected from dto.phone.clone() + phone_for_verification: dto.phone_for_verification.clone(), is_active: dto.is_active, gender: dto.gender.clone(), birthdate: dto.birthdate.clone(), + domicile: dto.domicile.clone(), + bio: dto.bio.clone(), + last_education: dto.last_education.clone(), + linkedin_url: dto.linkedin_url.clone(), + github_url: dto.github_url.clone(), + cv_url: dto.cv_url.clone(), + portfolio_url: dto.portfolio_url.clone(), + website_url: dto.website_url.clone(), + twitter_url: dto.twitter_url.clone(), + location: dto.location.clone(), + skills: dto.skills.clone(), + experience: dto.experience.clone(), + education: dto.education.clone(), + career_status: dto.career_status.clone(), created_at: dto.created_at.clone(), updated_at: dto.updated_at.clone(), } } + + pub fn from_schema(schema: &UsersSchema) -> Self { + Self { + id: schema.id.id.to_raw(), + role: RolesDetailItemDto::default(), // Placeholder, role needs to be fetched + fullname: schema.fullname.clone(), + legal_name: schema.legal_name.clone(), + email: schema.email.clone(), + avatar: schema.avatar.clone(), + phone_number: schema.phone_number.clone(), + phone_for_verification: schema.phone_for_verification.clone(), + is_active: schema.is_active, + gender: schema.gender.clone(), + birthdate: schema.birthdate.clone(), + domicile: schema.domicile.clone(), + bio: schema.bio.clone(), + last_education: schema.last_education.clone(), + linkedin_url: schema.linkedin_url.clone(), + github_url: schema.github_url.clone(), + cv_url: schema.cv_url.clone(), + portfolio_url: schema.portfolio_url.clone(), + website_url: schema.website_url.clone(), + twitter_url: schema.twitter_url.clone(), + location: schema.location.clone(), + skills: schema.skills.clone(), + experience: schema.experience.clone(), + education: schema.education.clone(), + career_status: schema.career_status.clone(), + created_at: schema.created_at.clone(), + updated_at: schema.updated_at.clone(), + } + } } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -131,7 +264,7 @@ pub struct UsersListQueryDto { pub id: Thing, pub role: RolesDetailQueryDto, pub fullname: String, - pub email: String, + pub email: String, // Corrected from pub pub email: String, pub avatar: Option, pub phone_number: String, pub is_active: bool, @@ -159,13 +292,29 @@ impl UsersListQueryDto { pub struct UsersDetailQueryDto { pub id: Thing, pub fullname: String, + pub legal_name: Option, pub email: String, pub avatar: Option, pub phone_number: String, + pub phone_for_verification: Option, pub is_active: bool, pub is_deleted: bool, pub gender: Option, pub birthdate: Option, + pub domicile: Option, + pub bio: Option, + pub last_education: Option, + pub linkedin_url: Option, + pub github_url: Option, + pub cv_url: Option, + pub portfolio_url: Option, + pub website_url: Option, + pub twitter_url: Option, + pub location: Option, + pub skills: Option>, + pub experience: Option>, + pub education: Option>, + pub career_status: Option, pub password: String, pub role: RolesDetailQueryDto, pub created_at: String, @@ -179,12 +328,28 @@ impl UsersDetailQueryDto { id: self.id.clone(), role: self.role.clone(), fullname: self.fullname.clone(), + legal_name: self.legal_name.clone(), email: self.email.clone(), avatar: self.avatar.clone(), phone_number: self.phone_number.clone(), + phone_for_verification: self.phone_for_verification.clone(), is_active: self.is_active, mentor_id: self.mentor_id.clone(), gender: self.gender.clone(), + domicile: self.domicile.clone(), + bio: self.bio.clone(), + last_education: self.last_education.clone(), + linkedin_url: self.linkedin_url.clone(), + github_url: self.github_url.clone(), + cv_url: self.cv_url.clone(), + portfolio_url: self.portfolio_url.clone(), + website_url: self.website_url.clone(), + twitter_url: self.twitter_url.clone(), + location: self.location.clone(), + skills: self.skills.clone(), + experience: self.experience.clone(), + education: self.education.clone(), + career_status: self.career_status.clone(), is_deleted: self.is_deleted, password: self.password.clone(), birthdate: self.birthdate.clone(), @@ -199,18 +364,40 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto { Self { id: crate::make_thing(&imphnen_libs::ResourceEnum::Users.to_string(), &dto.id), fullname: dto.fullname.clone(), + legal_name: dto.legal_name.clone(), email: dto.email.clone(), avatar: dto.avatar.clone(), phone_number: dto.phone_number.clone(), + phone_for_verification: dto.phone_for_verification.clone(), is_active: dto.is_active, is_deleted: false, gender: dto.gender.clone(), birthdate: dto.birthdate.clone(), + domicile: dto.domicile.clone(), + bio: dto.bio.clone(), + last_education: dto.last_education.clone(), + linkedin_url: dto.linkedin_url.clone(), + github_url: dto.github_url.clone(), + cv_url: dto.cv_url.clone(), + portfolio_url: dto.portfolio_url.clone(), + website_url: dto.website_url.clone(), + twitter_url: dto.twitter_url.clone(), + location: dto.location.clone(), + skills: dto.skills.clone(), + experience: dto.experience.clone(), + education: dto.education.clone(), + career_status: dto.career_status.clone(), password: String::new(), role: RolesDetailQueryDto::default(), created_at: dto.created_at.clone(), updated_at: dto.updated_at.clone(), mentor_id: None, } - } + } } + +impl UsersDetailItemDto { + pub fn extract_permissions_from_user_role(&self) -> Vec { + self.role.permissions.iter().map(|p| p.name.clone()).collect() + } +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/users/users_schema.rs b/imphnen-iam/src/v1/users/users_schema.rs index 62ea6bf..521b2f6 100644 --- a/imphnen-iam/src/v1/users/users_schema.rs +++ b/imphnen-iam/src/v1/users/users_schema.rs @@ -1,4 +1,4 @@ -use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto}; +use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto, ExperienceDto, EducationDto}; use imphnen_libs::{ResourceEnum, hash_password}; use imphnen_utils::extract_id; use imphnen_utils::{get_iso_date, make_thing}; @@ -9,11 +9,15 @@ use surrealdb::{Uuid, sql::Thing}; pub struct UsersSchema { pub id: Thing, pub fullname: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, pub email: String, pub password: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, pub phone_number: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_for_verification: Option, pub is_active: bool, pub is_deleted: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -22,6 +26,34 @@ pub struct UsersSchema { pub gender: Option, #[serde(skip_serializing_if = "Option::is_none")] pub birthdate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domicile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_education: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub linkedin_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cv_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub portfolio_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub twitter_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub experience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub education: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub career_status: Option, pub role: Thing, pub created_at: String, pub updated_at: String, @@ -35,18 +67,31 @@ impl Default for UsersSchema { &Uuid::new_v4().to_string(), ), fullname: String::new(), + legal_name: None, email: String::new(), password: hash_password("").unwrap(), avatar: None, phone_number: String::new(), + phone_for_verification: None, is_active: false, is_deleted: false, - mentor_id: Some(make_thing( - &ResourceEnum::Users.to_string(), - &Uuid::new_v4().to_string(), - )), + mentor_id: None, // Regular users should not have a mentor_id by default gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: make_thing( &ResourceEnum::Roles.to_string(), "5713cb37-dc02-4e87-8048-d7a41d352059", @@ -62,19 +107,30 @@ impl UsersSchema { Self { id: dto.id, fullname: dto.fullname, + legal_name: dto.legal_name, email: dto.email, avatar: dto.avatar, phone_number: dto.phone_number, + phone_for_verification: dto.phone_for_verification, is_active: dto.is_active, is_deleted: dto.is_deleted, - mentor_id: Some(dto.mentor_id.unwrap_or_else(|| { - make_thing( - &ResourceEnum::Users.to_string(), - &Uuid::new_v4().to_string(), - ) - })), + mentor_id: dto.mentor_id, // Use the actual mentor_id from the DTO, could be None gender: dto.gender, birthdate: dto.birthdate, + domicile: dto.domicile, + bio: dto.bio, + last_education: dto.last_education, + linkedin_url: dto.linkedin_url, + github_url: dto.github_url, + cv_url: dto.cv_url, + portfolio_url: dto.portfolio_url, + website_url: dto.website_url, + twitter_url: dto.twitter_url, + location: dto.location, + skills: dto.skills, + experience: dto.experience, + education: dto.education, + career_status: dto.career_status, password: dto.password, created_at: dto.created_at, updated_at: dto.updated_at, @@ -82,23 +138,101 @@ impl UsersSchema { } } - pub fn update(user: UsersUpdateRequestDto, id: String) -> Self { + pub fn update(_user: UsersUpdateRequestDto, id: String) -> Self { Self { id: make_thing(&ResourceEnum::Users.to_string(), &id), - fullname: user.fullname, - email: user.email, - phone_number: user.phone_number, - is_active: user.is_active, - gender: user.gender, - birthdate: user.birthdate, - avatar: user.avatar, - is_deleted: false, - role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), updated_at: get_iso_date(), + // Set defaults for required fields - these should be overridden by actual data from DB ..Default::default() } } + pub fn partial_update(current_user: UsersDetailQueryDto, user: UsersUpdateRequestDto) -> Self { + let mut schema = Self::from(current_user); + schema.updated_at = get_iso_date(); + + // Only update fields that are provided (Some) + if let Some(fullname) = user.fullname { + schema.fullname = fullname; + } + if let Some(email) = user.email { + schema.email = email; + } + if let Some(password) = user.password { + schema.password = hash_password(&password).unwrap_or_else(|_| password); + } + if let Some(phone_number) = user.phone_number { + schema.phone_number = phone_number; + } + if let Some(is_active) = user.is_active { + schema.is_active = is_active; + } + if let Some(role_id) = user.role_id { + schema.role = make_thing(&ResourceEnum::Roles.to_string(), &role_id); + } + + // Optional fields - only update if provided + if let Some(legal_name) = user.legal_name { + schema.legal_name = Some(legal_name); + } + if let Some(phone_for_verification) = user.phone_for_verification { + schema.phone_for_verification = Some(phone_for_verification); + } + if let Some(gender) = user.gender { + schema.gender = Some(gender); + } + if let Some(birthdate) = user.birthdate { + schema.birthdate = Some(birthdate); + } + if let Some(domicile) = user.domicile { + schema.domicile = Some(domicile); + } + if let Some(bio) = user.bio { + schema.bio = Some(bio); + } + if let Some(last_education) = user.last_education { + schema.last_education = Some(last_education); + } + if let Some(linkedin_url) = user.linkedin_url { + schema.linkedin_url = Some(linkedin_url); + } + if let Some(github_url) = user.github_url { + schema.github_url = Some(github_url); + } + if let Some(cv_url) = user.cv_url { + schema.cv_url = Some(cv_url); + } + if let Some(portfolio_url) = user.portfolio_url { + schema.portfolio_url = Some(portfolio_url); + } + if let Some(website_url) = user.website_url { + schema.website_url = Some(website_url); + } + if let Some(twitter_url) = user.twitter_url { + schema.twitter_url = Some(twitter_url); + } + if let Some(location) = user.location { + schema.location = Some(location); + } + if let Some(skills) = user.skills { + schema.skills = Some(skills); + } + if let Some(experience) = user.experience { + schema.experience = Some(experience); + } + if let Some(education) = user.education { + schema.education = Some(education); + } + if let Some(career_status) = user.career_status { + schema.career_status = Some(career_status); + } + if let Some(avatar) = user.avatar { + schema.avatar = Some(avatar); + } + + schema + } + pub fn create(user: UsersCreateRequestDto) -> Self { let password = hash_password(&user.password).unwrap(); Self { @@ -107,17 +241,30 @@ impl UsersSchema { &Uuid::new_v4().to_string(), ), fullname: user.fullname, + legal_name: None, email: user.email, password, phone_number: user.phone_number, + phone_for_verification: None, is_active: false, - mentor_id: Some(make_thing( - &ResourceEnum::Users.to_string(), - &Uuid::new_v4().to_string(), - )), + mentor_id: None, // Regular users should not have a mentor_id by default gender: None, birthdate: None, - avatar: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, + avatar: user.avatar, is_deleted: false, role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), created_at: get_iso_date(), @@ -136,10 +283,7 @@ impl UsersSchema { pub fn update_mentor_id(mut self, mentor_id: Option) -> Self { self.mentor_id = match mentor_id { Some(id) => Some(make_thing(&ResourceEnum::Users.to_string(), &id)), - None => Some(make_thing( - &ResourceEnum::Users.to_string(), - &Uuid::new_v4().to_string(), - )), + None => None, // Set to None if no mentor_id provided }; self.updated_at = get_iso_date(); self diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index 74f02d8..69a18ad 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -1,26 +1,68 @@ use super::{ - UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersSetNewPasswordRequestDto, UsersUpdateRequestDto, + users_dto::UsersDetailQueryDto, // Add this line }; use crate::{ AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema, }; use crate::{ - ResponseSuccessDto, common_response, extract_email, success_list_response, + ResponseSuccessDto, common_response, success_list_response, success_response, validate_request, }; -use axum::http::HeaderMap; -use axum::{http::StatusCode, response::Response}; -use imphnen_libs::{ResourceEnum, hash_password, verify_password}; +use axum::{http::StatusCode, response::Response, extract::Multipart}; +use imphnen_libs::{ResourceEnum, hash_password, verify_password, MinioConfig, FileType, decode_base64_file, extract_content_type_from_data_url, create_minio_service_from_config}; use imphnen_utils::make_thing; use uuid::Uuid; +use std::pin::Pin; +use std::future::Future; +use anyhow::Result; + +use tracing::info; +use tracing::error; +use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto}; +use serde_json::json; -pub struct UsersService; - -impl UsersService { - pub async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response { - let repo = UsersRepository::new(state); +pub trait UsersServiceTrait: Send + Sync + 'static { + fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; + fn get_user_by_id(state: &AppState, id: String) -> Pin + Send>>; + fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin + Send>>; + fn create_user(state: &AppState, new_user: UsersCreateRequestDto) -> Pin + Send>>; + fn update_user(state: &AppState, id: String, user: UsersUpdateRequestDto) -> Pin + Send>>; + fn update_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState, user: UsersUpdateRequestDto) -> Pin + Send>>; + fn set_user_active_status(state: &AppState, id: String, payload: UsersActiveInactiveRequestDto) -> Pin + Send>>; + fn update_user_password(state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto) -> Pin + Send>>; + fn get_user_by_mentor_id(state: &AppState, mentor_id: String) -> Pin + Send>>; + fn delete_user(state: &AppState, id: String) -> Pin + Send>>; + fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin> + Send>>; + + fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin>> + Send>>; + fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin> + Send>>; + fn update_user_avatar(email: &str, avatar_url: Option, state: &AppState) -> Pin> + Send>>; + fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Pin + Send>>; + } + + #[derive(Clone)] + pub struct UsersService; + + impl UsersService { + } + + + impl UsersServiceTrait for UsersService { + fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin> + Send>> { + let id = id.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = crate::UsersRepository::new(&state); + repo.query_user_by_id(&id).await + }) + } + fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); match repo.query_user_list(meta).await { Ok(data) => { let response = ResponseListSuccessDto { @@ -31,46 +73,55 @@ impl UsersService { } Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - pub async fn get_user_by_id(state: &AppState, id: String) -> Response { + + fn get_user_by_id(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { - data: UsersDetailItemDto::from(&user), + data: UserDto::from(&user), // Corrected to use UserDto::from by reference }), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - pub async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response { - let repo = UsersRepository::new(state); - let email = match extract_email(&headers) { - Some(email) => email, - None => return common_response(StatusCode::UNAUTHORIZED, "Invalid token"), - }; - match repo.query_user_by_email(email).await { + + fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin + Send>> { + let claims = claims.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id); + match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { - data: UsersDetailItemDto::from(&user), + data: UserDto::from(&user), }), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - pub async fn create_user( + + fn create_user( state: &AppState, new_user: UsersCreateRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let new_user = new_user; + Box::pin(async move { if let Err((status, message)) = validate_request(&new_user) { return common_response(status, &message); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); if repo .query_user_by_email(new_user.email.clone()) .await @@ -84,60 +135,83 @@ impl UsersService { common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) } } + }) } - - pub async fn update_user( + + fn update_user( state: &AppState, id: String, user: UsersUpdateRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + let user = user; + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); if let Err((status, message)) = validate_request(&user) { return common_response(status, &message); } - let updated_user = UsersSchema::update(user, id); - match repo.query_update_user(updated_user).await { - Ok(msg) => common_response(StatusCode::OK, &msg), - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), - } - } - - pub async fn update_user_me( - state: &AppState, - headers: HeaderMap, - user: UsersUpdateRequestDto, - ) -> Response { - let repo = UsersRepository::new(state); - let email = match extract_email(&headers) { - Some(email) => email, - None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), - }; - let user_data = match repo.query_user_by_email(email.clone()).await { + + // Get current user data first + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); + let current_user = match repo.query_user_by_id(&thing_id).await { Ok(user) => user, Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"), }; - if let Err((status, message)) = validate_request(&user) { - return common_response(status, &message); - } - let updated_user = UsersSchema::update(user, user_data.id.id.to_raw()); + + let updated_user = UsersSchema::partial_update(current_user, user); match repo.query_update_user(updated_user).await { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - pub async fn set_user_active_status( + + fn update_user_me( + claims: imphnen_libs::jsonwebtoken::Claims, + state: &AppState, + user_update_dto: UsersUpdateRequestDto, + ) -> Pin + Send>> { + let claims = claims.to_owned(); + let state = state.to_owned(); + let user_update_dto = user_update_dto; + Box::pin(async move { + let repo = UsersRepository::new(&state); + + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id); + let user_data = match repo.query_user_by_id(&thing_id).await { + Ok(user) => user, + Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"), + }; + + if let Err((status, message)) = validate_request(&user_update_dto) { + return common_response(status, &message); + } + + let updated_user = UsersSchema::partial_update(user_data, user_update_dto); + match repo.query_update_user(updated_user).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + }) + } + + fn set_user_active_status( state: &AppState, id: String, payload: UsersActiveInactiveRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + let payload = payload; + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => { @@ -154,14 +228,19 @@ impl UsersService { Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), } + }) } - - pub async fn update_user_password( + + fn update_user_password( state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto, - ) -> Response { - let repo = UsersRepository::new(state); + ) -> Pin + Send>> { + let state = state.to_owned(); + let email = email.to_owned(); + let payload = payload; + Box::pin(async move { + let repo = UsersRepository::new(&state); let user = match repo.query_user_by_email(email.clone()).await { Ok(user) if !user.is_deleted => user, _ => return common_response(StatusCode::NOT_FOUND, "User not found"), @@ -197,28 +276,36 @@ impl UsersService { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - pub async fn get_user_by_mentor_id( + + fn get_user_by_mentor_id( state: &AppState, mentor_id: String, - ) -> Response { - let repo = UsersRepository::new(state); + ) -> Pin + Send>> { + let state = state.to_owned(); + let mentor_id = mentor_id.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { - data: UsersDetailItemDto::from(&user), + data: UserDto::from(&user), // Corrected to use UserDto::from by reference }), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - pub async fn delete_user(state: &AppState, id: String) -> Response { + + fn delete_user(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); if repo.query_user_by_id(&thing_id).await.is_err() { return common_response(StatusCode::BAD_REQUEST, "User not found"); @@ -227,5 +314,306 @@ impl UsersService { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } -} + + fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin>> + Send>> { + let email = email.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); + let user = repo.query_user_by_email(email.to_string()).await; + match user { + Ok(u) => Ok(Some(UserDto::from(&u))), + Err(e) if e.to_string().contains("User not found") => Ok(None), + Err(e) => Err(anyhow::anyhow!(e.to_string())), + } + }) + } + + fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin> + Send>> { + let new_user = new_user; + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); + let email_clone = new_user.email.clone(); + let user_schema = UsersSchema { + email: new_user.email, + password: new_user.password, + fullname: new_user.fullname, + phone_number: new_user.phone_number, + is_active: new_user.is_active, + avatar: new_user.avatar, + role: make_thing(&ResourceEnum::Roles.to_string(), &new_user.role_id), + ..Default::default() + }; + match repo.query_create_user(user_schema).await { + Ok(_msg) => { + let created_user = repo.query_user_by_email(email_clone).await?; + Ok(UserDto::from(&created_user)) + }, + Err(e) => Err(anyhow::anyhow!(e.to_string())), + } + }) + } + + fn update_user_avatar(email: &str, avatar_url: Option, state: &AppState) -> Pin> + Send>> { + let email = email.to_owned(); + let avatar_url = avatar_url.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); + + let mut user = repo.query_user_by_email(email.to_string()).await + .map_err(|e| anyhow::anyhow!("Failed to get user: {}", e))?; + + user.avatar = avatar_url; + + let user_schema = UsersSchema::from(user); + match repo.query_update_user(user_schema).await { + Ok(_) => { + info!("Successfully updated avatar for user: {}", email); + Ok(()) + }, + Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)), + } + }) + } + + fn upload_file(state: &AppState, user_id: String, mut multipart: Multipart) -> Pin + Send>> { + let state = state.to_owned(); + let user_id = user_id.to_owned(); + Box::pin(async move { + info!("Entering upload_file function for user_id: {}", user_id); + + // Initialize MinIO configuration + let minio_config = match MinioConfig::from_env() { + Ok(config) => { + info!("MinIO config loaded successfully."); + config + }, + Err(e) => { + error!("Failed to load MinIO config: {}", e); + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "MinIO configuration error", + ); + } + }; + + // Store bucket name before minio_config is moved + let bucket_name = minio_config.bucket_name.clone(); + info!("MinIO bucket name: {}", bucket_name); + + // Initialize MinIO service + let minio_service = match create_minio_service_from_config(minio_config).await { + Ok(service) => { + info!("MinIO service initialized successfully."); + service + }, + Err(e) => { + error!("Failed to initialize MinIO service: {}", e); + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "MinIO service initialization error", + ); + } + }; + + // Get actual user data from database using user_id (which is a UUID) + let repo = UsersRepository::new(&state); + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &user_id); + let user_data = match repo.query_user_by_id(&thing_id).await { + Ok(user) => { + info!("Found user in DB. User ID: {}, Email: {}", user.id.id.to_raw(), user.email); + user + } + Err(e) => { + error!("Failed to find user in DB for ID {}: {}", user_id, e); + return common_response( + StatusCode::NOT_FOUND, + "User not found", + ); + } + }; + let actual_user_id = user_data.id.id.to_raw(); + let user_email = user_data.email; + + let mut file_data: Option> = None; + let mut filename: Option = None; + let mut content_type: Option = None; + + info!("Starting multipart form processing."); + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap_or("").to_string(); + info!("Processing multipart field: {}", name); + + match name.as_str() { + "file" => { + filename = field.file_name().map(|s| s.to_string()); + content_type = field.content_type().map(|s| s.to_string()); + info!("Detected file field. Filename: {:?}, Content-Type: {:?}", filename, content_type); + + match field.bytes().await { + Ok(bytes) => { + file_data = Some(bytes.to_vec()); + info!("Successfully read file data, size: {} bytes", file_data.as_ref().map_or(0, |d| d.len())); + }, + Err(e) => { + error!("Failed to read file data from multipart: {}", e); + return common_response( + StatusCode::BAD_REQUEST, + "Failed to read file data", + ); + } + } + } + "base64_data" => { + let base64_str = field.text().await.unwrap_or_default(); + info!("Detected base64_data field, length: {}", base64_str.len()); + if !base64_str.is_empty() { + match decode_base64_file(&base64_str) { + Ok(decoded_data) => { + file_data = Some(decoded_data); + info!("Successfully decoded base64 data, size: {} bytes", file_data.as_ref().map_or(0, |d| d.len())); + if let Some(detected_type) = extract_content_type_from_data_url(&base64_str) { + content_type = Some(detected_type); + info!("Detected content type from base64 data URL: {}", content_type.as_ref().unwrap()); + } + } + Err(e) => { + error!("Failed to decode base64 data: {}", e); + return common_response( + StatusCode::BAD_REQUEST, + "Invalid base64 data", + ); + } + } + } + } + "filename" => { + filename = Some(field.text().await.unwrap_or_default()); + info!("Received filename from field: {:?}", filename); + } + "content_type" => { + content_type = Some(field.text().await.unwrap_or_default()); + info!("Received content_type from field: {:?}", content_type); + } + _ => { + info!("Skipping unknown multipart field: {}", name); + } + } + } + info!("Finished multipart form processing."); + + // Validate required fields + let file_data = match file_data { + Some(data) => data, + None => { + error!("File data is missing after multipart processing."); + return common_response( + StatusCode::BAD_REQUEST, + "file data is required", + ); + } + }; + info!("File data extracted, size: {} bytes.", file_data.len()); + + let filename = filename.unwrap_or_else(|| { + info!("Filename not provided, defaulting to 'unnamed_file'."); + "unnamed_file".to_string() + }); + let content_type = content_type.unwrap_or_else(|| { + info!("Content type not provided, defaulting to 'application/octet-stream'."); + "application/octet-stream".to_string() + }); + info!("Final filename: {}, Content-Type: {}", filename, content_type); + + // Auto-detect file type based on content type and filename + let file_type = FileType::from_content_type(&content_type); + let file_type = if matches!(file_type, FileType::Unknown) { + info!("Content type detection failed, trying from filename."); + FileType::from_filename(&filename) + } else { + file_type + }; + info!("Detected file type: {:?}", file_type); + + // Validate file type is supported + if matches!(file_type, FileType::Unknown) { + error!("Unsupported file type detected: {:?}", file_type); + return common_response( + StatusCode::BAD_REQUEST, + "Unsupported file type. Supported types: JPEG, PNG, WEBP, GIF, PDF, DOC, DOCX", + ); + } + + // Validate file type matches content type + if !file_type.allowed_types().contains(&content_type.as_str()) { + error!("File type '{:?}' does not match content type '{}'.", file_type, content_type); + return common_response( + StatusCode::BAD_REQUEST, + &format!("File type '{:?}' does not match content type '{}'", file_type, content_type), + ); + } + + // Validate file size + if file_data.len() > file_type.max_size() { + error!("File too large. Current size: {} bytes, Max size for {:?}: {} bytes", + file_data.len(), file_type, file_type.max_size()); + return common_response( + StatusCode::BAD_REQUEST, + &format!("File too large. Maximum size for {:?} is {} bytes", + file_type, file_type.max_size()), + ); + } + info!("File size validated: {} bytes.", file_data.len()); + + // Create secure upload path with user ID (sanitized for filesystem) + let sanitized_user_id = user_email + .replace("%", "") + .replace(":", "_") + .replace("@", "_at_") + .replace(".", "_"); + info!("Sanitized user ID for folder path: {}", sanitized_user_id); + + let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id); + info!("Upload folder: {}", folder); + + // Upload file to MinIO with deduplication + info!("Attempting to upload file to MinIO."); + match minio_service.upload_file_with_deduplication(&file_data, &content_type, &folder, &filename).await { + Ok(object_path) => { + info!("File uploaded successfully to MinIO. Object path: {}", object_path); + // Create permanent URL (no expiration) + let permanent_url = format!("https://cdn.asepharyana.tech/{}/{}", + bucket_name, object_path); + info!("Permanent URL: {}", permanent_url); + + let response_data = json!({ + "filename": filename, + "original_filename": filename, + "uploaded_path": object_path, + "url": permanent_url, + "size": file_data.len(), + "content_type": content_type, + "file_type": format!("{:?}", file_type).to_lowercase(), + "user_id": actual_user_id, + "email": user_email + }); + + success_response(ResponseSuccessDto { + data: response_data, + }) + } + Err(e) => { + error!("Failed to upload file to MinIO: {}", e); + common_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Upload failed: {}", e), + ) + } + } + }) + } +} \ No newline at end of file diff --git a/imphnen-libs/Cargo.toml b/imphnen-libs/Cargo.toml index 29fcc4f..03130db 100644 --- a/imphnen-libs/Cargo.toml +++ b/imphnen-libs/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] imphnen-entities.workspace = true +once_cell = { workspace = true } log.workspace = true axum.workspace = true tokio.workspace = true @@ -15,3 +16,11 @@ chrono.workspace = true surrealdb.workspace = true jsonwebtoken.workspace = true dotenvy.workspace = true +anyhow.workspace = true +uuid.workspace = true +base64.workspace = true +reqwest.workspace = true +sha2.workspace = true +hmac.workspace = true +hex.workspace = true +urlencoding.workspace = true diff --git a/imphnen-libs/src/argon/mod.rs b/imphnen-libs/src/argon/mod.rs index 6367b7b..84fe0af 100644 --- a/imphnen-libs/src/argon/mod.rs +++ b/imphnen-libs/src/argon/mod.rs @@ -8,7 +8,11 @@ use argon2::{ pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); + let argon2 = Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(1024, 1, 1, None).unwrap() // 1MB, 1 iteration, 1 thread (faster, less secure) + ); let password_hash = argon2 .hash_password(password.as_bytes(), &salt)? .to_string(); diff --git a/imphnen-libs/src/axum/mod.rs b/imphnen-libs/src/axum/mod.rs index e41ba93..0acc815 100644 --- a/imphnen-libs/src/axum/mod.rs +++ b/imphnen-libs/src/axum/mod.rs @@ -1,16 +1,16 @@ -use crate::{Env, load_env, surrealdb_init_mem, surrealdb_init_ws}; +use crate::{surrealdb_init_mem, surrealdb_init_ws}; use axum::{Router, serve}; use imphnen_entities::{SurrealMemClient, SurrealWsClient}; use std::{future::Future, net::SocketAddr}; use tokio::net::TcpListener; +use crate::enviroment::ENV; pub async fn axum_init(router_fn: F) where F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut, Fut: Future, { - load_env(); - let env = Env::new(); + let env = &ENV; let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws"); diff --git a/imphnen-libs/src/enviroment/mod.rs b/imphnen-libs/src/enviroment/mod.rs index 7c2fbe5..2740933 100644 --- a/imphnen-libs/src/enviroment/mod.rs +++ b/imphnen-libs/src/enviroment/mod.rs @@ -1,100 +1,128 @@ +//! Environment configuration module using once_cell::sync::Lazy for one-time loading. + use std::env; +use once_cell::sync::Lazy; +// Logging for warnings if .env is missing +use log::{warn, info}; -pub fn load_env() { - if dotenvy::dotenv().is_ok() {} -} - +/// Struct holding all environment configuration. +#[derive(Clone)] pub struct Env { - pub port: u16, - pub access_token_secret: String, - pub refresh_token_secret: String, - pub surrealdb_url: String, - pub surrealdb_username: String, - pub surrealdb_password: String, - pub surrealdb_namespace: String, - pub surrealdb_dbname: String, - pub surrealdb_url_ws: String, - pub smtp_email: String, - pub smtp_password: String, - pub smtp_name: String, - pub smtp_host: String, - pub redisdb_url: String, - pub fe_url: String, - pub rust_env: String, - pub minio_endpoint: String, - pub minio_bucket_name: String, - pub minio_access_key: String, - pub minio_secret_key: String, + pub port: u16, + pub access_token_secret: String, + pub refresh_token_secret: String, + pub surrealdb_url: String, + pub surrealdb_username: String, + pub surrealdb_password: String, + pub surrealdb_namespace: String, + pub surrealdb_dbname: String, + pub surrealdb_url_ws: String, + pub smtp_email: String, + pub smtp_password: String, + pub smtp_name: String, + pub smtp_host: String, + pub redisdb_url: String, + pub fe_url: String, + pub rust_env: String, + pub minio_endpoint: String, + pub minio_bucket_name: String, + pub minio_access_key: String, + pub minio_secret_key: String, + pub minio_region: String, + pub minio_secure: bool, + // Google OAuth 2.1 + pub google_client_id: String, + pub google_client_secret: String, + pub google_redirect_url: String, } -impl Env { - #[must_use] - pub fn new() -> Self { - Self { - port: env::var("PORT") - .unwrap_or_else(|_| "3000".to_string()) - .parse() - .unwrap_or(3000), - - rust_env: env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()), - - access_token_secret: env::var("ACCESS_TOKEN_SECRET") - .unwrap_or_else(|_| "default_access_secret".to_string()), - - refresh_token_secret: env::var("REFRESH_TOKEN_SECRET") - .unwrap_or_else(|_| "default_refresh_secret".to_string()), - - surrealdb_url: env::var("SURREALDB_URL") - .unwrap_or_else(|_| "http://localhost:8000".to_string()), - - surrealdb_username: env::var("SURREALDB_USERNAME") - .unwrap_or_else(|_| "root".to_string()), - - surrealdb_password: env::var("SURREALDB_PASSWORD") - .unwrap_or_else(|_| "password".to_string()), - - surrealdb_namespace: env::var("SURREALDB_NAMESPACE") - .unwrap_or_else(|_| "namespace".to_string()), - - surrealdb_dbname: env::var("SURREALDB_DBNAME") - .unwrap_or_else(|_| "database".to_string()), - - surrealdb_url_ws: env::var("SURREALDB_URL_WS") - .unwrap_or_else(|_| "ws://localhost:8000/rpc".to_string()), - - smtp_email: env::var("SMTP_EMAIL") - .unwrap_or_else(|_| "no-reply@example.com".to_string()), - - smtp_password: env::var("SMTP_PASSWORD") - .unwrap_or_else(|_| "default_smtp_password".to_string()), - - smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| "MyApp SMTP".to_string()), - - smtp_host: env::var("SMTP_HOST") - .unwrap_or_else(|_| "smtp.gmail.com".to_string()), - - redisdb_url: env::var("REDISDB_URL") - .unwrap_or_else(|_| "localhost".to_string()), - - fe_url: env::var("FE_URL").unwrap_or_else(|_| "http://localhost".to_string()), - - minio_endpoint: env::var("MINIO_ENDPOINT") - .unwrap_or_else(|_| "http://localhost:9000".to_string()), - - minio_bucket_name: env::var("MINIO_BUCKET_NAME") - .unwrap_or_else(|_| "default_bucket".to_string()), - - minio_access_key: env::var("MINIO_ACCESS_KEY") - .unwrap_or_else(|_| "minio_access".to_string()), - - minio_secret_key: env::var("MINIO_SECRET_KEY") - .unwrap_or_else(|_| "minio_secret".to_string()), - } - } +// Custom Debug implementation to mask secrets in logs +impl std::fmt::Debug for Env { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Env") + .field("port", &self.port) + .field("access_token_secret", &"***") + .field("refresh_token_secret", &"***") + .field("surrealdb_url", &self.surrealdb_url) + .field("surrealdb_username", &self.surrealdb_username) + .field("surrealdb_password", &"***") + .field("surrealdb_namespace", &self.surrealdb_namespace) + .field("surrealdb_dbname", &self.surrealdb_dbname) + .field("surrealdb_url_ws", &self.surrealdb_url_ws) + .field("smtp_email", &self.smtp_email) + .field("smtp_password", &"***") + .field("smtp_name", &self.smtp_name) + .field("smtp_host", &self.smtp_host) + .field("redisdb_url", &self.redisdb_url) + .field("fe_url", &self.fe_url) + .field("rust_env", &self.rust_env) + .field("minio_endpoint", &self.minio_endpoint) + .field("minio_bucket_name", &self.minio_bucket_name) + .field("minio_access_key", &"***") + .field("minio_secret_key", &"***") + .field("minio_region", &self.minio_region) + .field("minio_secure", &self.minio_secure) + .field("google_client_id", &self.google_client_id) + .field("google_client_secret", &"***") + .field("google_redirect_url", &self.google_redirect_url) + .finish() + } } -impl Default for Env { - fn default() -> Self { - Self::new() - } +/// Helper to get env var with warning if not set. +fn get_env_with_warning(key: &str, default: &str) -> String { + match env::var(key) { + Ok(val) => val, + Err(_) => { + warn!("Environment variable '{}' is not set. Using default: '{}'", key, default); + default.to_string() + } + } } + +/// Loads environment variables from .env and system, only once. +pub static ENV: Lazy = Lazy::new(|| { + // Try to load .env file, log a warning if not found, proceed regardless. + match dotenvy::dotenv() { + Ok(_) => {} + Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => { + warn!(".env file not found, falling back to system environment variables"); + } + Err(_) => {} + } + + let env = Env { + port: get_env_with_warning("PORT", "3000") + .parse() + .unwrap_or(3000), + access_token_secret: get_env_with_warning("ACCESS_TOKEN_SECRET", "default_access_secret"), + refresh_token_secret: get_env_with_warning("REFRESH_TOKEN_SECRET", "default_refresh_secret"), + surrealdb_url: get_env_with_warning("SURREALDB_URL", "http://localhost:8000"), + surrealdb_username: get_env_with_warning("SURREALDB_USERNAME", "root"), + surrealdb_password: get_env_with_warning("SURREALDB_PASSWORD", "root"), + surrealdb_namespace: get_env_with_warning("SURREALDB_NAMESPACE", "namespace"), + surrealdb_dbname: get_env_with_warning("SURREALDB_DBNAME", "database"), + smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"), + smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"), + smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"), + smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"), + redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"), + fe_url: get_env_with_warning("FE_URL", "http://localhost"), + rust_env: get_env_with_warning("RUST_ENV", "development"), + minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"), + minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"), + minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"), + minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"), + minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"), + minio_secure: get_env_with_warning("MINIO_SECURE", "false") + .parse() + .unwrap_or(false), + surrealdb_url_ws: String::new(), + // Google OAuth 2.1 + google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"), + google_client_secret: get_env_with_warning("GOOGLE_CLIENT_SECRET", "default_google_client_secret"), + google_redirect_url: get_env_with_warning("GOOGLE_REDIRECT_URL", "http://localhost:8000/api/v1/auth/google/callback"), + }; + info!("Loaded environment configuration: {:?}", env); + env +}); diff --git a/imphnen-libs/src/jsonwebtoken/mod.rs b/imphnen-libs/src/jsonwebtoken/mod.rs index c79c3f0..111420c 100644 --- a/imphnen-libs/src/jsonwebtoken/mod.rs +++ b/imphnen-libs/src/jsonwebtoken/mod.rs @@ -1,4 +1,4 @@ -use super::Env; +use crate::enviroment::ENV; use axum::http::StatusCode; use chrono::{Duration, TimeDelta, Utc}; use jsonwebtoken::{ @@ -11,32 +11,36 @@ pub struct Claims { pub exp: usize, pub iat: usize, pub sub: String, + pub user_id: String, + pub permissions: Vec, } -pub fn encode_access_token(sub: String) -> Result { - let env = Env::new(); - let secret: String = env.access_token_secret; +static ACCESS_HEADER: once_cell::sync::Lazy
= once_cell::sync::Lazy::new(Header::default); +static ACCESS_KEY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + EncodingKey::from_secret(ENV.access_token_secret.as_ref()) +}); +pub fn encode_access_token(sub: String, user_id: String, permissions: Vec) -> Result { let now = Utc::now(); let expire: TimeDelta = Duration::minutes(15); let exp: usize = (now + expire).timestamp() as usize; let iat: usize = now.timestamp() as usize; - let claim = Claims { iat, exp, sub }; + let claim = Claims { iat, exp, sub, user_id, permissions }; encode( - &Header::default(), + &ACCESS_HEADER, &claim, - &EncodingKey::from_secret(secret.as_ref()), + &ACCESS_KEY, ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } -pub fn encode_reset_password_token(sub: String) -> Result { - let env = Env::new(); - let secret: String = env.access_token_secret; +pub fn encode_reset_password_token(sub: String, user_id: String, permissions: Vec) -> Result { + let env = &ENV; + let secret: String = env.access_token_secret.clone(); let now = Utc::now(); let expire: TimeDelta = Duration::minutes(5); let exp: usize = (now + expire).timestamp() as usize; let iat: usize = now.timestamp() as usize; - let claim = Claims { iat, exp, sub }; + let claim = Claims { iat, exp, sub, user_id, permissions }; encode( &Header::default(), &claim, @@ -48,8 +52,8 @@ pub fn encode_reset_password_token(sub: String) -> Result { pub fn decode_access_token( jwt_token: &str, ) -> Result, StatusCode> { - let env = Env::new(); - let secret: String = env.access_token_secret; + let env = &ENV; + let secret: String = env.access_token_secret.clone(); let result: Result, StatusCode> = decode( jwt_token, &DecodingKey::from_secret(secret.as_ref()), @@ -59,18 +63,20 @@ pub fn decode_access_token( result } -pub fn encode_refresh_token(sub: String) -> Result { - let env = Env::new(); - let secret: String = env.refresh_token_secret; +static REFRESH_HEADER: once_cell::sync::Lazy
= once_cell::sync::Lazy::new(Header::default); +static REFRESH_KEY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + EncodingKey::from_secret(ENV.refresh_token_secret.as_ref()) +}); +pub fn encode_refresh_token(sub: String, user_id: String, permissions: Vec) -> Result { let now = Utc::now(); let expire: TimeDelta = Duration::days(1); let exp: usize = (now + expire).timestamp() as usize; let iat: usize = now.timestamp() as usize; - let claim = Claims { iat, exp, sub }; + let claim = Claims { iat, exp, sub, user_id, permissions }; encode( - &Header::default(), + &REFRESH_HEADER, &claim, - &EncodingKey::from_secret(secret.as_ref()), + &REFRESH_KEY, ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } @@ -78,13 +84,17 @@ pub fn encode_refresh_token(sub: String) -> Result { pub fn decode_refresh_token( jwt_token: &str, ) -> Result, StatusCode> { - let env = Env::new(); - let secret: String = env.refresh_token_secret; + let env = &ENV; + let secret: String = env.refresh_token_secret.clone(); let result: Result, StatusCode> = decode( jwt_token, &DecodingKey::from_secret(secret.as_ref()), &Validation::default(), ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR); - result + result // Explicitly return result +} + +pub fn generate_jwt(user_id: &str, permissions: Vec) -> Result { + encode_access_token(user_id.to_string(), user_id.to_string(), permissions) } diff --git a/imphnen-libs/src/lettre/mod.rs b/imphnen-libs/src/lettre/mod.rs index e7a5fea..059cff2 100644 --- a/imphnen-libs/src/lettre/mod.rs +++ b/imphnen-libs/src/lettre/mod.rs @@ -1,4 +1,4 @@ -use super::Env; +use crate::enviroment::ENV; use lettre::message::Mailbox; use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; @@ -9,11 +9,11 @@ pub fn send_email( subject: &str, body: &str, ) -> Result<(), Box> { - let env = Env::new(); - let host = env.smtp_host; - let sender_email = env.smtp_email; - let sender_name = env.smtp_name; - let sender_password = env.smtp_password; + let env = &ENV; + let host = env.smtp_host.clone(); + let sender_email = env.smtp_email.clone(); + let sender_name = env.smtp_name.clone(); + let sender_password = env.smtp_password.clone(); let recipient_email = to; let email = Message::builder() .from(Mailbox::new( diff --git a/imphnen-libs/src/lib.rs b/imphnen-libs/src/lib.rs index 0678288..50d0efe 100644 --- a/imphnen-libs/src/lib.rs +++ b/imphnen-libs/src/lib.rs @@ -5,6 +5,7 @@ pub mod axum; pub mod enviroment; pub mod jsonwebtoken; pub mod lettre; +pub mod minio; pub mod surrealdb; pub use argon::*; @@ -13,4 +14,5 @@ pub use enviroment::*; pub use imphnen_entities::*; pub use jsonwebtoken::*; pub use lettre::*; +pub use minio::*; pub use surrealdb::*; diff --git a/imphnen-libs/src/minio.rs b/imphnen-libs/src/minio.rs new file mode 100644 index 0000000..f997d0f --- /dev/null +++ b/imphnen-libs/src/minio.rs @@ -0,0 +1,730 @@ +use anyhow::{anyhow, bail, Result}; +use base64::{engine::general_purpose, Engine as _}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use crate::enviroment::ENV; + + + +// --- Struct Konfigurasi MinIO --- +#[derive(Debug, Clone)] +pub struct MinioConfig { + pub endpoint: String, + pub access_key: String, + pub secret_key: String, + pub bucket_name: String, + pub region: String, + pub secure: bool, +} + +impl MinioConfig { + /// Memuat konfigurasi MinIO dari variabel lingkungan. + pub fn from_env() -> Result { + Ok(Self { + endpoint: ENV.minio_endpoint.clone(), + access_key: ENV.minio_access_key.clone(), + secret_key: ENV.minio_secret_key.clone(), + bucket_name: ENV.minio_bucket_name.clone(), + region: ENV.minio_region.clone(), + secure: ENV.minio_secure, + }) + } + + /// Mendapatkan URL endpoint lengkap (http atau https). + pub fn endpoint_url(&self) -> String { + // If endpoint already has protocol, use it as-is + if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") { + self.endpoint.clone() + } else { + // Only add protocol if not present + let protocol = if self.secure { "https" } else { "http" }; + format!("{}://{}", protocol, self.endpoint) + } + } +} + +// --- Layanan MinIO --- +pub struct MinioService { + endpoint: String, + access_key: String, + secret_key: String, + bucket_name: String, + region: String, + client: reqwest::Client, +} + +impl MinioService { + /// Membuat instance layanan MinIO baru. + pub async fn new( + endpoint: &str, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region: &str, + ) -> Result { + let service = Self { + endpoint: endpoint.to_string(), + access_key: access_key.to_string(), + secret_key: secret_key.to_string(), + bucket_name: bucket_name.to_string(), + region: region.to_string(), + client: reqwest::Client::new(), + }; + Ok(service) + } + + /// Mengunggah file biner ke MinIO dengan deduplication berdasarkan hash. + pub async fn upload_file_with_deduplication( + &self, + file_data: &[u8], + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + Self::validate_file_type(content_type, file_data)?; + + // Calculate file hash + let mut hasher = Sha256::new(); + hasher.update(file_data); + let file_hash = format!("{:x}", hasher.finalize()); + let short_hash = &file_hash[..16]; // Use first 16 characters for filename + + // Check if file with same hash already exists + if let Some(existing_file) = self.check_file_exists_by_hash(folder, short_hash).await? { + log::info!("File with same content already exists: {}", existing_file); + return Ok(existing_file); + } + + let file_extension = Self::get_file_extension(original_filename); + let unique_filename = format!("{}/{}-{}.{}", folder, short_hash, Uuid::new_v4(), file_extension); + let object_name = &unique_filename; + + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name); + + // Debug logging + log::debug!("MinIO Endpoint config: {}", self.endpoint); + log::debug!("MinIO Region config: {}", self.region); + log::debug!("Upload URL: {}", url); + log::debug!("Object name: {}", object_name); + log::debug!("File hash: {}", short_hash); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + + // Use UNSIGNED-PAYLOAD for simpler signature + let payload_hash = "UNSIGNED-PAYLOAD".to_string(); + + let canonical_headers = format!( + "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", + host, payload_hash, amz_date + ); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + + // For path-style, canonical URI should be /bucket/object + let canonical_uri = format!("/{}/{}", self.bucket_name, object_name); + let canonical_request = format!( + "PUT\n{}\n\n{}\n{}\n{}", + canonical_uri, canonical_headers, signed_headers, payload_hash + ); + + log::debug!("Canonical request:\n{}", canonical_request); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + log::debug!("Scope: {}", scope); + log::debug!("String to sign:\n{}", string_to_sign); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + log::debug!("Generated signature: {}", signature); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + // 5) Send request: Content-Type included but NOT signed + let response = self + .client + .put(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .header("Content-Type", content_type) + // Don't set Host header manually - let reqwest handle it + // Add headers for reverse proxy support (not signed) + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", host) + .body(file_data.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}", + status, + error_body + ); + } + + log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename); + Ok(unique_filename) + } + + /// Mengunggah file biner ke MinIO. + pub async fn upload_file( + &self, + file_data: &[u8], + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + Self::validate_file_type(content_type, file_data)?; + + let file_extension = Self::get_file_extension(original_filename); + let unique_filename = format!("{}/{}.{}", folder, Uuid::new_v4(), file_extension); + let object_name = &unique_filename; + + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name); + + // Debug logging + log::debug!("MinIO Endpoint config: {}", self.endpoint); + log::debug!("MinIO Region config: {}", self.region); + log::debug!("MinIO Access Key: {}", self.access_key); + log::debug!("MinIO Bucket: {}", self.bucket_name); + log::debug!("Extracted host: {}", host); + log::debug!("Final URL: {}", url); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + + // 1) Use UNSIGNED-PAYLOAD for HTTPS uploads (safer for proxies) + let payload_hash = "UNSIGNED-PAYLOAD".to_string(); + + // 2) Path-style canonical URI: /{bucket}/{object} + let canonical_uri = format!("/{}/{}", self.bucket_name, object_name); + + // 3) ONLY sign essential headers (no content-type to avoid proxy issues) + let canonical_headers = format!( + "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", + host, payload_hash, amz_date + ); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + + // 4) Canonical request + let canonical_request = format!( + "PUT\n{}\n\n{}\n{}\n{}", + canonical_uri, canonical_headers, signed_headers, payload_hash + ); + + // Debug logging + log::debug!("URL: {}", url); + log::debug!("Host: {}", host); + log::debug!("Bucket: {}", self.bucket_name); + log::debug!("Object: {}", object_name); + log::debug!("Canonical URI: {}", canonical_uri); + log::debug!("Payload hash: {}", payload_hash); + log::debug!("Canonical Request:\n{}", canonical_request); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + log::debug!("Scope: {}", scope); + log::debug!("String to sign:\n{}", string_to_sign); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + log::debug!("Generated signature: {}", signature); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + // 5) Send request: Content-Type included but NOT signed + let response = self + .client + .put(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .header("Content-Type", content_type) + // Don't set Host header manually - let reqwest handle it + // Add headers for reverse proxy support (not signed) + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", host) + .body(file_data.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}", + status, + error_body + ); + } + + log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename); + Ok(unique_filename) + } + + /// Mengunggah file yang dikodekan base64 ke MinIO. + pub async fn upload_base64_file( + &self, + base64_data: &str, + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + let file_data = decode_base64_file(base64_data)?; + self.upload_file(&file_data, content_type, folder, original_filename) + .await + } + + /// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek. + pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result { + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let credential = format!("{}/{}", self.access_key, scope); + + let expires_str = expiry_seconds.to_string(); + let mut query_params = std::collections::BTreeMap::new(); + query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + query_params.insert("X-Amz-Credential", &credential); + query_params.insert("X-Amz-Date", &amz_date); + query_params.insert("X-Amz-Expires", &expires_str); + query_params.insert("X-Amz-SignedHeaders", "host"); + + let canonical_query_string = query_params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + let canonical_request = format!( + "GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD", + self.bucket_name, object_name, canonical_query_string, host + ); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let url = format!( + "https://{}/{}/{}?{}&X-Amz-Signature={}", + host, self.bucket_name, object_name, canonical_query_string, signature + ); + + Ok(url) + } + + /// Mengecek apakah file dengan hash tertentu sudah ada di bucket + pub async fn check_file_exists_by_hash(&self, folder: &str, file_hash: &str) -> Result> { + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}?list-type=2&prefix={}", host, self.bucket_name, folder); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(b"")); + + let canonical_query_string = format!("list-type=2&prefix={}", urlencoding::encode(folder)); + let canonical_headers = format!("host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", host, payload_hash, amz_date); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "GET\n/{}\n{}\n{}\n{}\n{}", + self.bucket_name, canonical_query_string, canonical_headers, signed_headers, payload_hash + ); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + let response = self + .client + .get(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + return Ok(None); + } + + let body = response.text().await?; + + // Simple XML parsing to find files with matching hash + // Look for any file that contains the hash in its name + if body.contains(file_hash) { + // Extract the full file path from XML response + // This is a simplified approach - in production you might want proper XML parsing + for line in body.lines() { + if line.contains("") && line.contains(file_hash) { + if let Some(start) = line.find("") { + if let Some(end) = line.find("") { + let file_path = &line[start + 5..end]; + return Ok(Some(file_path.to_string())); + } + } + } + } + } + + Ok(None) + } + + /// Menghapus file dari MinIO. + pub async fn delete_file(&self, object_name: &str) -> Result<()> { + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(b"")); + + let canonical_headers = format!("host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", host, payload_hash, amz_date); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "DELETE\n/{}/{}\n\n{}\n{}\n{}", + self.bucket_name, object_name, canonical_headers, signed_headers, payload_hash + ); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + // Debug logging for signature calculation + log::debug!("Region: {}", self.region); + log::debug!("Scope: {}", scope); + log::debug!("String to sign:\n{}", string_to_sign); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + log::debug!("Final signature: {}", signature); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + let response = self + .client + .delete(&url) + .header("Host", host) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Gagal menghapus file dari MinIO. Status: {}. Pesan: {}", + status, + error_body + ); + } + + log::info!("File berhasil dihapus: {}", object_name); + Ok(()) + } + + /// Fungsi pembantu untuk menghasilkan kunci tanda tangan AWS v4. + fn get_signature_key(&self, date_stamp: &str) -> Result> { + let secret = format!("AWS4{}", self.secret_key); + let mut mac1 = Hmac::::new_from_slice(secret.as_bytes())?; + mac1.update(date_stamp.as_bytes()); + let date_key = mac1.finalize().into_bytes(); + + let mut mac2 = Hmac::::new_from_slice(&date_key)?; + mac2.update(self.region.as_bytes()); + let date_region_key = mac2.finalize().into_bytes(); + + let mut mac3 = Hmac::::new_from_slice(&date_region_key)?; + mac3.update(b"s3"); + let date_region_service_key = mac3.finalize().into_bytes(); + + let mut mac4 = Hmac::::new_from_slice(&date_region_service_key)?; + mac4.update(b"aws4_request"); + Ok(mac4.finalize().into_bytes().to_vec()) + } + + /// Memvalidasi jenis file dan ukuran. + fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> { + const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB + if file_data.len() > MAX_SIZE { + bail!("Ukuran file melebihi batas 10MB"); + } + + match content_type { + "image/jpeg" | "image/jpg" => { + if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) { + bail!("File JPEG tidak valid"); + } + } + "image/png" => { + if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { + bail!("File PNG tidak valid"); + } + } + "application/pdf" => { + if !file_data.starts_with(b"%PDF") { + bail!("File PDF tidak valid"); + } + } + "image/webp" => { + if !file_data.starts_with(b"RIFF") + || !file_data.get(8..12).map_or(false, |s| s == b"WEBP") + { + bail!("File WEBP tidak valid"); + } + } + "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + if file_data.len() < 512 { + bail!("File dokumen tidak valid"); + } + } + _ => { + bail!("Jenis file tidak didukung: {}", content_type); + } + } + + Ok(()) + } + + /// Mendapatkan ekstensi file dari nama file. + fn get_file_extension(filename: &str) -> String { + std::path::Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("bin") + .to_lowercase() + } +} + +// --- Struct dan Enum Pembantu --- + +#[derive(Debug, Clone)] +pub struct UploadResult { + pub object_name: String, + pub url: String, + pub size: usize, + pub content_type: String, +} + +#[derive(Debug, Clone)] +pub enum FileType { + Jpeg, + Png, + Webp, + Gif, + Pdf, + Doc, + Docx, + Unknown, +} + +impl FileType { + pub fn as_folder(&self) -> &str { + match self { + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles", + FileType::Pdf | FileType::Doc | FileType::Docx => "documents", + FileType::Unknown => "misc", + } + } + + pub fn max_size(&self) -> usize { + match self { + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => 5 * 1024 * 1024, // 5MB for images + FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, // 10MB for documents + FileType::Unknown => 5 * 1024 * 1024, // 5MB default + } + } + + pub fn allowed_types(&self) -> Vec<&str> { + match self { + FileType::Jpeg => vec!["image/jpeg", "image/jpg"], + FileType::Png => vec!["image/png"], + FileType::Webp => vec!["image/webp"], + FileType::Gif => vec!["image/gif"], + FileType::Pdf => vec!["application/pdf"], + FileType::Doc => vec!["application/msword"], + FileType::Docx => vec!["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + FileType::Unknown => vec![], // No allowed types for unknown + } + } + + pub fn from_content_type(content_type: &str) -> Self { + match content_type { + "image/jpeg" | "image/jpg" => FileType::Jpeg, + "image/png" => FileType::Png, + "image/webp" => FileType::Webp, + "image/gif" => FileType::Gif, + "application/pdf" => FileType::Pdf, + "application/msword" => FileType::Doc, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => FileType::Docx, + _ => FileType::Unknown, + } + } + + pub fn from_filename(filename: &str) -> Self { + let filename_lower = filename.to_lowercase(); + if filename_lower.ends_with(".jpg") || filename_lower.ends_with(".jpeg") { + FileType::Jpeg + } else if filename_lower.ends_with(".png") { + FileType::Png + } else if filename_lower.ends_with(".webp") { + FileType::Webp + } else if filename_lower.ends_with(".gif") { + FileType::Gif + } else if filename_lower.ends_with(".pdf") { + FileType::Pdf + } else if filename_lower.ends_with(".doc") { + FileType::Doc + } else if filename_lower.ends_with(".docx") { + FileType::Docx + } else { + FileType::Unknown + } + } +} + +#[derive(Debug, Clone)] +pub struct UploadRequest { + pub user_id: String, + pub file_type: FileType, + pub filename: String, + pub content_type: String, + pub data: Vec, +} + +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub filename: String, + pub content_type: String, + pub size: usize, + pub path: String, + pub url: String, +} + +// --- Fungsi Pembantu --- + +/// Membuat instance MinioService dari struct MinioConfig. +pub async fn create_minio_service_from_config(config: MinioConfig) -> Result { + MinioService::new( + &config.endpoint, // Use raw endpoint, not endpoint_url() + &config.access_key, + &config.secret_key, + &config.bucket_name, + &config.region, + ) + .await +} + +/// Mendekode data file base64. +pub fn decode_base64_file(base64_data: &str) -> Result> { + let clean_data = if base64_data.contains(',') { + base64_data.split(',').nth(1).unwrap_or(base64_data) + } else { + base64_data + }; + + general_purpose::STANDARD + .decode(clean_data) + .map_err(|e| anyhow!("Gagal mendekode data base64: {}", e)) +} + +/// Mengekstrak tipe konten dari URL data. +pub fn extract_content_type_from_data_url(data_url: &str) -> Option { + if data_url.starts_with("data:") { + if let Some(type_part) = data_url.split(';').next() { + return Some(type_part.replace("data:", "")); + } + } + None +} diff --git a/imphnen-libs/src/surrealdb/mod.rs b/imphnen-libs/src/surrealdb/mod.rs index e2ea1f1..e1e2b92 100644 --- a/imphnen-libs/src/surrealdb/mod.rs +++ b/imphnen-libs/src/surrealdb/mod.rs @@ -1,6 +1,5 @@ -use super::Env; +use crate::enviroment::ENV; use crate::SurrealMemClient; -use crate::enviroment::load_env; use surrealdb::engine::any; use surrealdb::engine::local::Mem; use surrealdb::opt::auth::Root; @@ -10,8 +9,7 @@ pub mod resource; pub use resource::*; pub async fn surrealdb_init_ws() -> Result> { - load_env(); - let env = Env::new(); + let env = &ENV; let db = any::connect(&env.surrealdb_url).await?; db.signin(Root { @@ -26,7 +24,7 @@ pub async fn surrealdb_init_ws() -> Result> { } pub async fn surrealdb_init_mem() -> Result { - let env = Env::new(); + let env = &ENV; let db = Surreal::new::(()).await?; db.use_ns(&env.surrealdb_namespace) .use_db(&env.surrealdb_dbname) diff --git a/imphnen-middleware/Cargo.toml b/imphnen-middleware/Cargo.toml index 796ddc2..b194f42 100644 --- a/imphnen-middleware/Cargo.toml +++ b/imphnen-middleware/Cargo.toml @@ -9,6 +9,7 @@ imphnen-libs.workspace = true imphnen-utils.workspace = true imphnen-entities.workspace = true axum.workspace = true +axum-extra.workspace = true serde.workspace = true serde_json.workspace = true utoipa.workspace = true diff --git a/imphnen-middleware/src/auth_middleware/mod.rs b/imphnen-middleware/src/auth_middleware/mod.rs index f5c4f07..97ea6c4 100644 --- a/imphnen-middleware/src/auth_middleware/mod.rs +++ b/imphnen-middleware/src/auth_middleware/mod.rs @@ -2,43 +2,72 @@ use axum::{ Extension, extract::Request, http::StatusCode, middleware::Next, response::Response, }; -use imphnen_iam::{UsersDetailQueryDto, UsersRepository}; -use imphnen_libs::AppState; -use imphnen_utils::{common_response, extract_email}; +use imphnen_libs::{AppState, jsonwebtoken::decode_access_token}; +use imphnen_iam::v1::users::users_dto::UsersDetailQueryDto; +use imphnen_utils::common_response; +use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt}; use std::convert::Infallible; +use imphnen_iam::v1::users::{users_service::{UsersService, UsersServiceTrait}}; +use imphnen_libs::ResourceEnum; +use imphnen_utils::make_thing; pub async fn auth_middleware( Extension(state): Extension, mut req: Request, next: Next, ) -> Result { - let headers = req.headers(); - let email = match extract_email(headers) { - Some(email) => email, + let auth_header = match req + .headers() + .typed_get::>() { + Some(header) => header, + None => return Ok(common_response( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + )), + }; + + let token = auth_header.token(); + + let claims = match decode_access_token(token) { + Ok(token_data) => token_data.claims, + Err(_) => return Ok(common_response( + StatusCode::UNAUTHORIZED, + "Invalid or expired token", + )), + }; + + let user_id = claims.user_id.clone(); + + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &user_id); + + // Try SurrealDB mem first + let mem_db = &state.surrealdb_mem; + let mut user_data: Option = None; + if let Ok(opt_user) = mem_db.select(("users", &user_id)).await { + if let Some(user) = opt_user { + let user: imphnen_iam::v1::users::users_dto::UsersDetailQueryDto = user; + if !user.is_deleted && !user.role.is_deleted { + user_data = Some(user); + } + } + } + + // Fallback to main DB if not found in mem + let user_data = match user_data { + Some(user) => user, None => { - return Ok(common_response( - StatusCode::UNAUTHORIZED, - "Invalid or expired token", - )); + let repo = UsersService {}; + match repo.get_user_by_id_internal(&thing_id, &state).await { + Ok(user) => { + // Optionally: insert into mem for future requests + let _: Result, _> = mem_db.update(&thing_id.id.to_raw()).content(user.clone()).await; + user + }, + Err(_) => return Ok(common_response(StatusCode::UNAUTHORIZED, "User not found")), + } } }; - let repository = UsersRepository::new(&state); - let user: Option = - match repository.query_user_by_email(email).await { - Ok(user) => Some(user), - Err(err) => { - return Ok(common_response( - StatusCode::INTERNAL_SERVER_ERROR, - &err.to_string(), - )); - } - }; - if user.is_none() { - return Ok(common_response( - StatusCode::UNAUTHORIZED, - "Unauthorized user", - )); - } - req.extensions_mut().insert(user.unwrap()); + + req.extensions_mut().insert(user_data); Ok(next.run(req).await) } diff --git a/imphnen-middleware/src/cors_middleware/mod.rs b/imphnen-middleware/src/cors_middleware/mod.rs index e79553c..22c46d6 100644 --- a/imphnen-middleware/src/cors_middleware/mod.rs +++ b/imphnen-middleware/src/cors_middleware/mod.rs @@ -1,23 +1,27 @@ use axum::http::{HeaderValue, Method, header}; -use imphnen_libs::Env; +use imphnen_libs::enviroment::ENV; use tower_http::cors::CorsLayer; pub fn cors_middleware() -> CorsLayer { - let env = Env::new(); + let env = &ENV; let cors_origins = match env.rust_env.as_str() { - "development" => vec!["http://localhost:3000"], + "development" => { + let mut origins = vec!["http://localhost:3000".to_string()]; + origins.push(format!("http://localhost:{}", env.port)); + origins + }, "production" => { vec![ - "https://gacha.imphnen.dev", - "https://imphnen.dev", - "https://dimentorin.imphnen.dev", + "https://gacha.imphnen.dev".to_string(), + "https://imphnen.dev".to_string(), + "https://dimentorin.imphnen.dev".to_string(), ] } _ => vec![ - "http://localhost:3000", - "https://gacha.imphnen.dev", - "https://imphnen.dev", - "https://dimentorin.imphnen.dev", + "http://localhost:3000".to_string(), + "https://gacha.imphnen.dev".to_string(), + "https://imphnen.dev".to_string(), + "https://dimentorin.imphnen.dev".to_string(), ], }; let allowed_origins: Vec = cors_origins @@ -27,7 +31,7 @@ pub fn cors_middleware() -> CorsLayer { CorsLayer::new() .allow_origin(allowed_origins) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) .allow_credentials(true) -} +} \ No newline at end of file diff --git a/imphnen-middleware/src/permissions_middleware/mod.rs b/imphnen-middleware/src/permissions_middleware/mod.rs index 6f6b8a7..cff2da2 100644 --- a/imphnen-middleware/src/permissions_middleware/mod.rs +++ b/imphnen-middleware/src/permissions_middleware/mod.rs @@ -5,7 +5,7 @@ use axum::{ use futures::future::BoxFuture; use imphnen_iam::{AuthRepository, PermissionsEnum}; use imphnen_libs::AppState; -use imphnen_utils::{common_response, extract_email}; +use imphnen_utils::{common_response, extract_email, extract_email_async}; use std::task::{Context, Poll}; use tower::{Layer, Service}; @@ -59,15 +59,24 @@ where let permissions = self.permissions.clone(); Box::pin(async move { let headers = req.headers(); + + // Try synchronous email extraction first (for internal JWT tokens) let email = match extract_email(headers) { Some(email) => email, None => { - return Ok(common_response( - StatusCode::UNAUTHORIZED, - "Invalid or missing authorization token", - )); + // If sync extraction fails, try async (for Google tokens) + match extract_email_async(headers).await { + Some(email) => email, + None => { + return Ok(common_response( + StatusCode::UNAUTHORIZED, + "Invalid or missing authorization token", + )); + } + } } }; + let auth_repo = AuthRepository::new(&app_state); let user = match auth_repo.query_get_stored_user(email).await { Ok(user) => user, diff --git a/imphnen-utils/Cargo.toml b/imphnen-utils/Cargo.toml index a008f34..67e0a77 100644 --- a/imphnen-utils/Cargo.toml +++ b/imphnen-utils/Cargo.toml @@ -19,3 +19,8 @@ strum.workspace = true strum_macros.workspace = true uuid.workspace = true tracing.workspace = true +base64.workspace = true +sha2.workspace = true +reqwest.workspace = true +dotenvy = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/imphnen-utils/src/bind_filter.rs b/imphnen-utils/src/bind_filter.rs index fedf43c..ecaac3e 100644 --- a/imphnen-utils/src/bind_filter.rs +++ b/imphnen-utils/src/bind_filter.rs @@ -1,9 +1,14 @@ +use tracing::{info}; use surrealdb::engine::any; use surrealdb::method::Query; +/// Binds a filter value to the query under the key "filter". pub fn bind_filter_value( query: Query<'_, any::Any>, val: String, ) -> Query<'_, any::Any> { - query.bind(("filter", val)) + info!(?val, "bind_filter_value called with arguments"); + let result = query.bind(("filter", val.clone())); + info!(?val, "bind_filter_value returning query with bound filter"); + result } diff --git a/imphnen-utils/src/csrf_token.rs b/imphnen-utils/src/csrf_token.rs new file mode 100644 index 0000000..dabb871 --- /dev/null +++ b/imphnen-utils/src/csrf_token.rs @@ -0,0 +1,232 @@ +use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use sha2::{Sha256, Digest}; +use imphnen_entities::error_dto::error::Error; +use tracing::{info, error}; // Added this line + +#[derive(Debug, Serialize, Deserialize)] +struct CsrfPayload { + pub timestamp: u64, + pub random: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OAuthCsrfPayload { + pub timestamp: u64, + pub random: String, + pub pkce_verifier: String, +} + +/// Generate a signed CSRF token that can be validated without server-side storage +pub fn generate_csrf_token(secret: &str) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? + .as_secs(); + info!("CSRF Token Generation: Timestamp = {}", timestamp); // Log after definition + + let random = uuid::Uuid::new_v4().to_string(); + info!("CSRF Token Generation: Random string generated."); // Log after definition + + let payload = CsrfPayload { + timestamp, + random, + }; + + let payload_json = serde_json::to_string(&payload) + .map_err(|e| { // Changed to capture error + error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e); + Error::Auth("Failed to serialize CSRF payload".to_string()) + })?; + info!("CSRF Token Generation: Payload JSON = {}", payload_json); // Log after definition + + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + info!("CSRF Token Generation: Payload Base64 = {}", payload_b64); // Log after definition + + // Create signature + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + info!("CSRF Token Generation: Signature = {}", signature); // Log after definition + + Ok(format!("{}.{}", payload_b64, signature)) +} + +/// Generate a signed OAuth CSRF token with PKCE verifier +pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get timestamp".to_string()))? + .as_secs(); + info!("OAuth CSRF Token Generation: Timestamp = {}", timestamp); // Log after definition + + let random = uuid::Uuid::new_v4().to_string(); + info!("OAuth CSRF Token Generation: Random string generated."); // Log after definition + + let payload = OAuthCsrfPayload { + timestamp, + random, + pkce_verifier: pkce_verifier.to_string(), + }; + info!("OAuth CSRF Token Generation: PKCE Verifier = {}", pkce_verifier); // Log after use in payload + + let payload_json = serde_json::to_string(&payload) + .map_err(|e| { // Changed to capture error + error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e); + Error::Auth("Failed to serialize OAuth CSRF payload".to_string()) + })?; + info!("OAuth CSRF Token Generation: Payload JSON = {}", payload_json); // Log after definition + + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + info!("OAuth CSRF Token Generation: Payload Base64 = {}", payload_b64); // Log after definition + + // Create signature + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + info!("OAuth CSRF Token Generation: Signature = {}", signature); // Log after definition + + Ok(format!("{}.{}", payload_b64, signature)) +} + +/// Validate a CSRF token +pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err(Error::Auth("Invalid CSRF token format".to_string())); + } + + let payload_b64 = parts[0]; + let provided_signature = parts[1]; + + // Verify signature + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + if provided_signature != expected_signature { + return Err(Error::Auth("Invalid CSRF token signature".to_string())); + } + + // Decode and validate payload + let payload_json = URL_SAFE_NO_PAD.decode(payload_b64) + .map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?; + + let payload_str = String::from_utf8(payload_json) + .map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?; + + let payload: CsrfPayload = serde_json::from_str(&payload_str) + .map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?; + + // Check timestamp + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? + .as_secs(); + + if now > payload.timestamp + max_age_seconds { + return Err(Error::Auth("CSRF token has expired".to_string())); + } + + if payload.timestamp > now + 60 { // Allow 1 minute clock skew + return Err(Error::Auth("CSRF token timestamp is in the future".to_string())); + } + + Ok(()) +} + +/// Validate OAuth CSRF token and extract PKCE verifier +pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err(Error::Auth("Invalid OAuth CSRF token format".to_string())); + } + + let payload_b64 = parts[0]; + let provided_signature = parts[1]; + + // Verify signature + let mut hasher = Sha256::new(); + hasher.update(payload_b64.as_bytes()); + hasher.update(secret.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + if provided_signature != expected_signature { + return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string())); + } + + // Decode and validate payload + let payload_json = URL_SAFE_NO_PAD.decode(payload_b64) + .map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?; + + let payload_str = String::from_utf8(payload_json) + .map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?; + + let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str) + .map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?; + + // Check timestamp + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))? + .as_secs(); + + if now > payload.timestamp + max_age_seconds { + return Err(Error::Auth("OAuth CSRF token has expired".to_string())); + } + + if payload.timestamp > now + 60 { // Allow 1 minute clock skew + return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string())); + } + + Ok(payload.pkce_verifier) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_csrf_token_generation_and_validation() { + let secret = "test_secret"; + + // Generate token + let token = generate_csrf_token(secret).unwrap(); + + // Validate token (should pass) + assert!(validate_csrf_token(&token, secret, 300).is_ok()); + + // Validate with wrong secret (should fail) + assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err()); + } + + #[test] + fn test_csrf_token_expiration() { + let secret = "test_secret"; + let token = generate_csrf_token(secret).unwrap(); + + // Add a 2 second delay to ensure the token expires when max_age is 1 second + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Should fail with 1 second max age (token is now 2 seconds old) + assert!(validate_csrf_token(&token, secret, 1).is_err()); + + // Should still work with a large max age + assert!(validate_csrf_token(&token, secret, 300).is_ok()); + } + + #[test] + fn test_invalid_csrf_token_format() { + let secret = "test_secret"; + + // Invalid format (no dot) + assert!(validate_csrf_token("invalid_token", secret, 300).is_err()); + + // Invalid format (too many dots) + assert!(validate_csrf_token("a.b.c", secret, 300).is_err()); + } +} \ No newline at end of file diff --git a/imphnen-utils/src/extract_email.rs b/imphnen-utils/src/extract_email.rs index 6d61f3b..b37eb37 100644 --- a/imphnen-utils/src/extract_email.rs +++ b/imphnen-utils/src/extract_email.rs @@ -1,17 +1,170 @@ +use tracing::{info, error}; use crate::decode_access_token; use axum::http::{HeaderMap, header::AUTHORIZATION}; +/// Extracts the email from the Authorization header, if present and valid. +/// Supports both our internal JWT tokens and Google access tokens. pub fn extract_email(headers: &HeaderMap) -> Option { - let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?; - let token = auth_header.strip_prefix("Bearer ")?; - - match decode_access_token(token) { - Ok(data) => Some(data.claims.sub), - Err(_e) => None, - } + info!(?headers, "extract_email called with headers"); + let auth_header = match headers.get(AUTHORIZATION) { + Some(h) => h, + None => { + error!("Authorization header missing in extract_email"); + return None; + } + }; + let auth_str = match auth_header.to_str() { + Ok(s) => s, + Err(e) => { + error!(error = ?e, "Failed to convert Authorization header to str in extract_email"); + return None; + } + }; + let token = match auth_str.strip_prefix("Bearer ") { + Some(t) => t, + None => { + error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email"); + return None; + } + }; + info!(token, "Extracted bearer token in extract_email"); + + // First try to decode as our internal JWT token + match decode_access_token(token) { + Ok(data) => { + info!(email = %data.claims.sub, "Successfully decoded internal access token in extract_email"); + Some(data.claims.sub) + } + Err(_) => { + info!("Failed to decode as internal JWT, checking if it's a Google token"); + // If it fails, it might be a Google access token + // For Google tokens, we need async validation, so we'll return None here + // and handle Google tokens separately in the calling code + error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly."); + None + } + } } +/// Async version that can handle Google access tokens +pub async fn extract_email_async(headers: &HeaderMap) -> Option { + info!(?headers, "extract_email_async called with headers"); + let auth_header = match headers.get(AUTHORIZATION) { + Some(h) => h, + None => { + error!("Authorization header missing in extract_email_async"); + return None; + } + }; + let auth_str = match auth_header.to_str() { + Ok(s) => s, + Err(e) => { + error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async"); + return None; + } + }; + let token = match auth_str.strip_prefix("Bearer ") { + Some(t) => t, + None => { + error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async"); + return None; + } + }; + info!(token, "Extracted bearer token in extract_email_async"); + + // First try to decode as our internal JWT token + match decode_access_token(token) { + Ok(data) => { + info!(email = %data.claims.sub, "Successfully decoded internal access token in extract_email_async"); + Some(data.claims.sub) + } + Err(_) => { + info!("Failed to decode as internal JWT, trying Google token validation"); + // If it fails, try to validate as Google access token + extract_email_from_google_token(token).await + } + } +} + +/// Extracts email from Google access token by calling Google's tokeninfo endpoint +async fn extract_email_from_google_token(token: &str) -> Option { + use serde_json::Value; + + let client = reqwest::Client::new(); + let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={}", token); + + match client.get(&tokeninfo_url).send().await { + Ok(response) => { + if response.status().is_success() { + match response.json::().await { + Ok(token_info) => { + if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) { + info!(email = %email, "Successfully extracted email from Google token"); + Some(email.to_string()) + } else { + error!("Email not found in Google token info response"); + None + } + } + Err(e) => { + error!(error = ?e, "Failed to parse Google token info response"); + None + } + } + } else { + error!(status = %response.status(), "Google token validation failed"); + None + } + } + Err(e) => { + error!(error = ?e, "Failed to validate Google token"); + None + } + } +} + +/// Extracts the email from a JWT token string. +/// Supports both our internal JWT tokens and Google access tokens. pub fn extract_email_token(token: String) -> Option { - let token_data = decode_access_token(&token).ok()?; - Some(token_data.claims.sub) + info!(token = %token, "extract_email_token called with token"); + match decode_access_token(&token) { + Ok(data) => { + info!(email = %data.claims.sub, "Successfully decoded token in extract_email_token"); + Some(data.claims.sub) + } + Err(_) => { + info!("Failed to decode as internal JWT in extract_email_token, checking if it's a Google token"); + // If it fails, it might be a Google access token + // For Google tokens, we need async validation, so we'll return None here + // and handle Google tokens separately in the calling code + error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly."); + None + } + } } + +/// A simple helper to check if a token string looks like a JWT. +fn is_jwt(token: &str) -> bool { + let parts: Vec<_> = token.split('.').collect(); + parts.len() == 3 +} + +/// Async version of extract_email_token that can handle Google access tokens +pub async fn extract_email_token_async(token: String) -> Option { + info!(token = %token, "extract_email_token_async called with token"); + + if is_jwt(&token) { + match decode_access_token(&token) { + Ok(data) => { + info!(email = %data.claims.sub, "Successfully decoded internal token in extract_email_token_async"); + return Some(data.claims.sub); + } + Err(_) => { + info!("Failed to decode as internal JWT in extract_email_token_async, trying Google token validation"); + } + } + } + + // If it's not a valid internal JWT, try to validate as Google access token + extract_email_from_google_token(&token).await +} \ No newline at end of file diff --git a/imphnen-utils/src/generate_date.rs b/imphnen-utils/src/generate_date.rs index 7970532..c2d637a 100644 --- a/imphnen-utils/src/generate_date.rs +++ b/imphnen-utils/src/generate_date.rs @@ -1,6 +1,11 @@ +use tracing::{info}; use chrono::{DateTime, Utc}; +/// Returns the current UTC date/time as an RFC3339 string. pub fn get_iso_date() -> String { - let now: DateTime = Utc::now(); - now.to_rfc3339() + info!("get_iso_date called"); + let now: DateTime = Utc::now(); + let date_str = now.to_rfc3339(); + info!(date_str = %date_str, "get_iso_date returning RFC3339 date string"); + date_str } diff --git a/imphnen-utils/src/get_id.rs b/imphnen-utils/src/get_id.rs index 3901507..6a6af20 100644 --- a/imphnen-utils/src/get_id.rs +++ b/imphnen-utils/src/get_id.rs @@ -1,15 +1,29 @@ +use tracing::{info, error}; use anyhow::{Result, bail}; use surrealdb::sql::Thing; +/// Extracts the table and id from a Thing, returning (&str, &str). pub fn get_id(thing: &Thing) -> Result<(&str, &str)> { - let table = thing.tb.as_str(); - let id = match &thing.id { - surrealdb::sql::Id::String(s) => s.as_str(), - _ => bail!("Unsupported ID type"), - }; - Ok((table, id)) + info!(?thing, "get_id called with argument"); + let table = thing.tb.as_str(); + let id = match &thing.id { + surrealdb::sql::Id::String(s) => { + info!(id = %s, "ID extracted as string in get_id"); + s.as_str() + } + other => { + error!(?other, "Unsupported ID type in get_id"); + bail!("Unsupported ID type"); + } + }; + info!(table = %table, id = %id, "get_id returning table and id"); + Ok((table, id)) } +/// Extracts the raw id string from a Thing. pub fn extract_id(thing: &Thing) -> String { - thing.id.to_raw() + info!(?thing, "extract_id called with argument"); + let raw_id = thing.id.to_raw(); + info!(raw_id = %raw_id, "extract_id returning raw id string"); + raw_id } diff --git a/imphnen-utils/src/lib.rs b/imphnen-utils/src/lib.rs index a8b4713..29b68f6 100644 --- a/imphnen-utils/src/lib.rs +++ b/imphnen-utils/src/lib.rs @@ -1,3 +1,4 @@ +pub mod logger; pub mod bind_filter; pub mod extract_email; pub mod generate_date; @@ -9,9 +10,11 @@ pub mod query_list; pub mod response_format; pub mod serde_helpers; pub mod validator; +pub mod csrf_token; +pub use logger::init_logger; pub use bind_filter::*; -pub use extract_email::*; +pub use extract_email::{extract_email, extract_email_async, extract_email_token, extract_email_token_async}; pub use generate_date::*; pub use generate_otp::*; pub use get_id::*; @@ -26,3 +29,4 @@ pub use serde_helpers::{ string_or_empty_string, thing_or_string, }; pub use validator::*; +pub use csrf_token::*; diff --git a/imphnen-utils/src/logger.rs b/imphnen-utils/src/logger.rs new file mode 100644 index 0000000..3fd2602 --- /dev/null +++ b/imphnen-utils/src/logger.rs @@ -0,0 +1,18 @@ +use dotenvy::dotenv; +use tracing_subscriber::{EnvFilter, fmt}; + +/// Initializes the logger using tracing and tracing-subscriber. +/// Loads environment variables from `.env` and sets log level from `RUST_LOG`. +pub fn init_logger() { + dotenv().ok(); + + + // Set up the tracing subscriber with EnvFilter from RUST_LOG + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("warn")) + .unwrap(); + + fmt() + .with_env_filter(filter) + .init(); +} diff --git a/imphnen-utils/src/response_format.rs b/imphnen-utils/src/response_format.rs index ec9285b..bcba3b8 100644 --- a/imphnen-utils/src/response_format.rs +++ b/imphnen-utils/src/response_format.rs @@ -43,3 +43,14 @@ pub fn common_response(status: StatusCode, message: &str) -> Response { ) .into_response() } + +pub fn success_created_response(params: ResponseSuccessDto) -> Response { + ( + StatusCode::CREATED, + Json(json!({ + "data": params.data, + "version": "0.1.0", + })), + ) + .into_response() +} diff --git a/test.sh b/test.sh index 340ac25..12607b2 100644 --- a/test.sh +++ b/test.sh @@ -21,8 +21,10 @@ SKIP_COMPREHENSIVE=false SKIP_CRUD=false GENERATE_REPORT=false VERBOSE=false +SKIP_CLEAR=false +SKIP_SEED=false -while getopts "sbcrgvh" opt; do +while getopts "sbcrgvhkd" opt; do case ${opt} in s ) START_SERVER=true ;; b ) SKIP_BASIC=true ;; @@ -30,7 +32,7 @@ while getopts "sbcrgvh" opt; do r ) SKIP_CRUD=true ;; g ) GENERATE_REPORT=true ;; v ) VERBOSE=true ;; - h ) + h ) echo "IMPHNEN API Test Suite" echo "Usage: $0 [OPTIONS]" echo "" @@ -42,6 +44,8 @@ while getopts "sbcrgvh" opt; do echo " -g Generate JSON test report" echo " -v Verbose output (show all INFO logs)" echo " -h Show this help message" + echo " -d Skip database clear" + echo " -k Skip database seeding" echo "" echo "Examples:" echo " $0 # Run all tests" @@ -49,11 +53,18 @@ while getopts "sbcrgvh" opt; do echo " $0 -g # Run tests and generate report" echo " $0 -sv # Start server with verbose output" echo " $0 -bc # Run only public endpoint tests" + echo " $0 -d # Skip database clear" + echo " $0 -k # Skip database seeding" + echo " $0 -dk # Skip database clear and seeding" exit 0 ;; + d ) SKIP_CLEAR=true ;; + k ) SKIP_SEED=true ;; \? ) echo "Invalid option: -$OPTARG" >&2; echo "Use -h for help" >&2; exit 1 ;; esac done +# Shift past the options +shift "$((OPTIND-1))" if ! command -v curl &> /dev/null; then echo "Error: 'curl' tidak ditemukan. Mohon install terlebih dahulu." >&2 @@ -72,6 +83,7 @@ FALED_TESTS_SUMMARY=() PASS_COUNT=0 FAIL_COUNT=0 TEST_TESTIMONIAL_ID="" +TEST_EVENT_ID="" CYAN='\033[0;36m' YELLOW='\033[0;33m' @@ -162,8 +174,12 @@ test_server_connection() { } clear_database() { + if [ "$SKIP_CLEAR" = true ]; then + write_test_log "INFO" "Melewatkan pembersihan database." + return + fi write_test_log "INFO" "Membersihkan database via WebSocket..." - if ! cargo run --bin clear_db_test --release; then + if ! RUST_LOG=debug cargo run --bin clear_db_test --release; then write_test_log "ERROR" "Gagal membersihkan database." exit 1 fi @@ -433,7 +449,7 @@ test_comprehensive_with_user() { ;; "mentor@example.com") - test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true + test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true @@ -448,7 +464,7 @@ test_comprehensive_with_user() { ;; "user@example.com") - test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true + test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true @@ -471,7 +487,7 @@ test_comprehensive_with_user() { test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true ;; *) - test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 403 "" true + test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true ;; esac @@ -522,7 +538,7 @@ test_authentication_endpoints() { local forgot_password_data forgot_password_data=$(jq -n --arg email "$TEST_EMAIL" '{email: $email}') - test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 400 "$forgot_password_data" + test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 200 "$forgot_password_data" local new_password_data new_password_data=$(jq -n --arg token "some_reset_token" --arg pass "newpassword123!A" '{token: $token, password: $pass}') @@ -602,9 +618,9 @@ test_crud_operations() { local testimonial_data testimonial_data=$(jq -n --arg content "Test testimonial via Bash $(date +%s)" '{role: "Student", content: $content}') - local testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true) + local testimonial_response + testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true) TEST_TESTIMONIAL_ID=$(echo "$testimonial_response" | jq -r '.data.id // empty') - write_test_log "INFO" "TEST_TESTIMONIAL_ID: $TEST_TESTIMONIAL_ID" write_test_log "INFO" "Captured Testimonial ID: $TEST_TESTIMONIAL_ID" sleep 0.2 @@ -627,7 +643,10 @@ test_crud_operations() { end_date: "2025-12-01T16:00:00Z", location: null }') - test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true + local event_response + event_response=$(test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true) + TEST_EVENT_ID=$(echo "$event_response" | jq -r '.data.id // empty') + write_test_log "INFO" "Captured Event ID: $TEST_EVENT_ID" } test_roles_and_permissions() { @@ -687,8 +706,12 @@ test_events_endpoints() { test_api_endpoint "Get Events with Pagination" "GET" "/v1/cms/landing/events?page=1&per_page=5" 200 test_api_endpoint "Get Events with Search" "GET" "/v1/cms/landing/events?search=tech" 200 - local test_event_id="e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o" - test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$test_event_id" 200 + # Ensure TEST_EVENT_ID is not empty before testing + if [ -n "$TEST_EVENT_ID" ]; then + test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$TEST_EVENT_ID" 200 + else + write_test_log "WARN" "✗ Get Event By ID - Dilewati: TEST_EVENT_ID tidak tersedia" + fi } test_testimonials_endpoints() { @@ -776,7 +799,7 @@ if [ "$START_SERVER" = true ]; then exit 1 fi printf "${YELLOW}Memulai server backend...${NC}\n" - cargo run --bin api & + RUST_LOG=debug cargo run --bin api & SERVER_PID=$! printf "${YELLOW}Menunggu server siap...${NC}\n" @@ -803,11 +826,15 @@ fi clear_database printf "\n${CYAN}=== Menjalankan Seeders ===${NC}\n" -if ! cargo run --bin seeder; then - write_test_log "ERROR" "Gagal menjalankan seeder roles permissions." - exit 1 +if [ "$SKIP_SEED" = true ]; then + write_test_log "INFO" "Melewatkan seeding database." +else + if ! RUST_LOG=debug cargo run --bin seeder; then + write_test_log "ERROR" "Gagal menjalankan seeder roles permissions." + exit 1 + fi + write_test_log "SUCCESS" "Seeders selesai." fi -write_test_log "SUCCESS" "Seeders selesai." printf "\n${CYAN}=== Menampilkan User yang Tersedia ===${NC}\n" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 24d892d..5a68fd6 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -24,6 +24,6 @@ serde.workspace = true strum.workspace = true uuid.workspace = true rand.workspace = true -async-trait.workspace = true + chrono.workspace = true axum.workspace = true \ No newline at end of file diff --git a/tests/src/iam/auth/auth_login_tests.rs b/tests/src/iam/auth/auth_login_tests.rs index d11047a..25c372d 100644 --- a/tests/src/iam/auth/auth_login_tests.rs +++ b/tests/src/iam/auth/auth_login_tests.rs @@ -5,7 +5,7 @@ mod auth_login_tests { use crate::mock_test::setup_all_test_environment; use axum::http::StatusCode; use imphnen_iam::{ - v1::auth::{AuthLoginRequestDto, AuthService}, + v1::auth::{AuthLoginRequestDto, AuthService, AuthServiceTrait}, // Import AuthServiceTrait AppState, UsersRepository, UsersSchema, }; use serde_json::Value; // Import the new setup function @@ -47,13 +47,29 @@ mod auth_login_tests { id: crate::make_thing("app_users", &uuid::Uuid::new_v4().to_string()), email: email.to_string(), fullname: "Test User".to_string(), + legal_name: None, password: hash_password(password).unwrap(), is_deleted: false, avatar: None, phone_number: "081234567890".to_string(), + phone_for_verification: None, is_active, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: crate::make_thing("app_roles", &role.id), mentor_id: None, created_at: imphnen_utils::get_iso_date(), @@ -72,7 +88,7 @@ mod auth_login_tests { async fn test_successful_login_with_valid_credentials() { let state = setup_test_environment().await; let email = generate_unique_email("test_login_success"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", true).await; @@ -81,7 +97,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::OK); @@ -105,10 +121,10 @@ mod auth_login_tests { let login_dto = AuthLoginRequestDto { email: "invalid-email".to_string(), - password: "TestPass123!".to_string(), + password: "password".to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -124,10 +140,10 @@ mod auth_login_tests { let login_dto = AuthLoginRequestDto { email: "".to_string(), - password: "TestPass123!".to_string(), + password: "password".to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -147,7 +163,7 @@ mod auth_login_tests { password: "".to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -160,7 +176,7 @@ mod auth_login_tests { async fn test_login_with_wrong_password() { let state = setup_test_environment().await; let email = generate_unique_email("test_wrong_pass"); - let correct_password = "TestPass123!"; + let correct_password = "password"; create_test_user_with_role(&state, &email, correct_password, "User", true).await; @@ -169,7 +185,7 @@ mod auth_login_tests { password: "WrongPassword123!".to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -186,10 +202,10 @@ mod auth_login_tests { let login_dto = AuthLoginRequestDto { email: generate_unique_email("nonexistent"), - password: "TestPass123!".to_string(), + password: "password".to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::UNAUTHORIZED); @@ -206,7 +222,7 @@ mod auth_login_tests { async fn test_login_with_inactive_user() { let state = setup_test_environment().await; let email = generate_unique_email("test_inactive"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", false).await; @@ -215,7 +231,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -233,7 +249,7 @@ mod auth_login_tests { async fn test_successful_mentor_login() { let state = setup_test_environment().await; let email = generate_unique_email("test_mentor_login"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "Mentor", true).await; @@ -242,7 +258,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_mentor_login(login_dto, &state).await; + let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::OK); @@ -258,7 +274,7 @@ mod auth_login_tests { async fn test_mentor_login_with_non_mentor_user() { let state = setup_test_environment().await; let email = generate_unique_email("test_user_not_mentor"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", true).await; @@ -267,7 +283,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_mentor_login(login_dto, &state).await; + let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::FORBIDDEN); @@ -285,7 +301,7 @@ mod auth_login_tests { async fn test_mentor_login_with_inactive_mentor() { let state = setup_test_environment().await; let email = generate_unique_email("test_inactive_mentor"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "Mentor", false).await; @@ -294,7 +310,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_mentor_login(login_dto, &state).await; + let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -312,7 +328,7 @@ mod auth_login_tests { async fn test_login_creates_user_cache() { let state = setup_test_environment().await; let email = generate_unique_email("test_cache"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", true).await; @@ -321,7 +337,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, _) = response.into_parts(); assert_eq!(parts.status, StatusCode::OK); @@ -337,7 +353,7 @@ mod auth_login_tests { async fn test_login_with_special_characters_in_email() { let state = setup_test_environment().await; let email = generate_unique_email("test+special"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", true).await; @@ -346,7 +362,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, _) = response.into_parts(); assert_eq!(parts.status, StatusCode::OK); @@ -356,7 +372,7 @@ mod auth_login_tests { async fn test_login_with_case_sensitive_email() { let state = setup_test_environment().await; let email = generate_unique_email("test_case"); - let password = "TestPass123!"; + let password = "password"; create_test_user_with_role(&state, &email, password, "User", true).await; @@ -365,7 +381,7 @@ mod auth_login_tests { password: password.to_string(), }; - let response = AuthService::mutation_login(login_dto, &state).await; + let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call let (parts, body) = response.into_parts(); // Email should be case-sensitive diff --git a/tests/src/iam/auth/auth_repository_test.rs b/tests/src/iam/auth/auth_repository_test.rs index f2cccbe..d9dfca5 100644 --- a/tests/src/iam/auth/auth_repository_test.rs +++ b/tests/src/iam/auth/auth_repository_test.rs @@ -21,13 +21,29 @@ mod auth_repository_test { id: make_thing("app_users", &Uuid::new_v4().to_string()), email: email.to_string(), fullname: "Test User".to_string(), + legal_name: None, password: "password".to_string(), is_deleted: false, avatar: None, phone_number: "081234567890".to_string(), + phone_for_verification: None, is_active: true, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: make_thing("app_roles", &get_role_id(state).await), mentor_id: None, created_at: get_iso_date(), @@ -71,12 +87,28 @@ mod auth_repository_test { let mock_user = UsersDetailQueryDto { id: make_thing(&ResourceEnum::UsersCache.to_string(), &email), fullname: "Test User".into(), + legal_name: None, email: email.clone(), avatar: None, phone_number: "08123456789".into(), + phone_for_verification: None, is_active: true, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: RolesDetailQueryDto { id: make_thing("app_roles", &Uuid::new_v4().to_string()), name: "Dummy Role".into(), diff --git a/tests/src/iam/auth/google/google_oauth_flow_test.rs b/tests/src/iam/auth/google/google_oauth_flow_test.rs new file mode 100644 index 0000000..550f74a --- /dev/null +++ b/tests/src/iam/auth/google/google_oauth_flow_test.rs @@ -0,0 +1,280 @@ +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{self, Request, StatusCode}, + Router, + }; + use http_body_util::BodyExt; // for `collect` and `to_bytes` + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use mockall::predicate::*; + use mockall::mock; + use serde_json::json; + + use imphnen_iam::v1::auth::{AuthLoginResponsetDto, TokenDto}; + use imphnen_iam::v1::auth::google::google_oauth_controller::GoogleOauthController; + use imphnen_iam::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl}; + use imphnen_iam::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto}; // Corrected: removed UserDto alias, used UsersCreateRequestDto + use imphnen_entities::error_dto::ErrorResponse; + use imphnen_libs::jsonwebtoken::generate_jwt; + use imphnen_libs::enviroment::{ENV, Env}; // Import ENV and Env + + mock! { + pub GoogleOauthServiceMock {} + impl GoogleOauthService for GoogleOauthServiceMock { + fn new() -> Self; + fn with_services(auth_service: crate::v1::auth::auth_service::AuthService, users_service: crate::v1::users::users_service::UsersService, env: &'static Env) -> Self; // Updated signature + fn google_oauth_client(&self) -> oauth2::basic::BasicClient; + fn generate_auth_url(&self) -> (url::Url, oauth2::CsrfToken); + async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &crate::AppState) -> anyhow::Result<(crate::v1::users::users_dto::UsersDetailItemDto, crate::v1::auth::TokenDto), imphnen_entities::error_dto::error::Error>; + } + } + + mock! { + pub AuthServiceMock {} + impl crate::v1::auth::auth_service::AuthServiceTrait for AuthServiceMock { + async fn mutation_login(payload: crate::v1::auth::AuthLoginRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_mentor_login(payload: crate::v1::auth::AuthLoginRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_register(payload: crate::v1::auth::AuthRegisterRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_resend_otp(payload: crate::v1::auth::AuthResendOtpRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_refresh_token(payload: crate::v1::auth::AuthRefreshTokenRequestDto) -> axum::response::Response; + async fn mutation_forgot_password(payload: crate::v1::auth::AuthResendOtpRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_verify_email(payload: crate::v1::auth::AuthVerifyEmailRequestDto, state: &crate::AppState) -> axum::response::Response; + async fn mutation_new_password(payload: crate::v1::auth::AuthNewPasswordRequestDto, state: &crate::AppState) -> axum::response::Response; + } + } + + mock! { + pub UsersServiceMock {} + impl crate::v1::users::users_service::UsersServiceTrait for UsersServiceMock { + async fn get_user_list(state: &crate::AppState, meta: crate::MetaRequestDto) -> axum::response::Response; + async fn get_user_by_id(state: &crate::AppState, id: String) -> axum::response::Response; + async fn get_user_me(headers: http::HeaderMap, state: &crate::AppState) -> axum::response::Response; + async fn create_user(state: &crate::AppState, new_user: crate::v1::users::UsersCreateRequestDto) -> axum::response::Response; + async fn update_user(state: &crate::AppState, id: String, user: crate::v1::users::UsersUpdateRequestDto) -> axum::response::Response; + async fn update_user_me(state: &crate::AppState, headers: http::HeaderMap, user: crate::v1::users::UsersUpdateRequestDto) -> axum::response::Response; + async fn set_user_active_status(state: &crate::AppState, id: String, payload: crate::v1::users::UsersActiveInactiveRequestDto) -> axum::response::Response; + async fn update_user_password(state: &crate::AppState, email: String, payload: crate::v1::users::UsersSetNewPasswordRequestDto) -> axum::response::Response; + async fn get_user_by_mentor_id(state: &crate::AppState, mentor_id: String) -> axum::response::Response; + async fn delete_user(state: &crate::AppState, id: String) -> axum::response::Response; + async fn get_user_by_email(&self, email: &str) -> anyhow::Result>; // Updated return type + async fn create_user_by_dto(&self, new_user: UsersCreateRequestDto) -> anyhow::Result; // Updated return type + } + } + + async fn setup_app_with_mocked_google_oauth_service( + mock_google_oauth_service: MockGoogleOauthServiceMock, + ) -> Router { + let google_oauth_controller = + GoogleOauthController::with_service(mock_google_oauth_service); + google_oauth_controller.get_routes() + } + + #[tokio::test] + async fn google_login_redirects_to_google_auth_url() { + let app = setup_app_with_mocked_google_oauth_service( + GoogleOauthServiceImpl::with_services( + AuthServiceMock::new(), + UsersServiceMock::new(), + &ENV, // Pass ENV + ) + ).await; + + let request = Request::builder() + .uri("/google/login") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::FOUND); + + let location_header = response.headers().get(http::header::LOCATION).unwrap(); + let location_str = location_header.to_str().unwrap(); + + assert!(location_str.starts_with("https://accounts.google.com/o/oauth2/v2/auth")); + assert!(location_str.contains("client_id=")); + assert!(location_str.contains("redirect_uri=")); + assert!(location_str.contains("response_type=code")); + assert!(location_str.contains("scope=")); + assert!(location_str.contains("state=")); + assert!(location_str.contains("access_type=offline")); + assert!(location_str.contains("prompt=consent")); + } + + #[tokio::test] + async fn google_callback_new_user_creates_user_and_returns_login_response() { + let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new(); + let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock + + let user_email = "new.user@example.com".to_string(); + let expected_access_token = generate_jwt(&user_email, "test_user_id", vec![]).unwrap(); + let expected_refresh_token = generate_jwt(&user_email, "test_user_id", vec![]).unwrap(); + + let expected_response_dto = AuthLoginResponsetDto { + token: TokenDto { + access_token: expected_access_token.clone(), + refresh_token: expected_refresh_token.clone(), + }, + user: UsersDetailItemDto { + id: "test_user_id".to_string(), + email: user_email.clone(), + fullname: "Test User".to_string(), // Updated field + phone_number: "1234567890".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }, + }; + + mock_google_oauth_service.expect_google_oauth_callback() + .with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() })) + .returning(move |_| Ok(expected_response_dto.clone())); + + mock_users_service.expect_get_user_by_email() + .with(eq(user_email.clone())) + .returning(|_| Ok(None)); // Simulate no existing user + + mock_users_service.expect_create_user_by_dto() + .returning(|create_user_dto| { + Ok(UsersDetailItemDto { // Changed to UsersDetailItemDto + id: "test_user_id".to_string(), + email: create_user_dto.email, + fullname: create_user_dto.fullname, // Updated field + phone_number: create_user_dto.phone_number, // Updated field + is_active: create_user_dto.is_active, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }) + }); + + let app = setup_app_with_mocked_google_oauth_service( + GoogleOauthServiceImpl::with_services( + AuthServiceMock::new(), + mock_users_service, + &ENV, // Pass ENV here + ) + ).await; + + let request = Request::builder() + .uri("/google/callback?code=some_code&state=some_state") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(json_body.token.access_token, expected_access_token); + assert_eq!(json_body.token.refresh_token, expected_refresh_token); + assert_eq!(json_body.user.email, user_email); + } + + #[tokio::test] + async fn google_callback_existing_user_returns_login_response() { + let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new(); + let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock + + let user_email = "existing.user@example.com".to_string(); + let expected_access_token = generate_jwt(&user_email, "existing_user_id", vec![]).unwrap(); + let expected_refresh_token = generate_jwt(&user_email, "existing_user_id", vec![]).unwrap(); + + let existing_user_dto = UsersDetailItemDto { // Changed from UserDto to UsersDetailItemDto + id: "existing_user_id".to_string(), + email: user_email.clone(), + fullname: "Existing User".to_string(), // Updated field + phone_number: "0987654321".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }; + + let expected_response_dto = AuthLoginResponsetDto { + token: TokenDto { + access_token: expected_access_token.clone(), + refresh_token: expected_refresh_token.clone(), + }, + user: existing_user_dto.clone(), // Cloned + }; + + mock_google_oauth_service.expect_google_oauth_callback() + .with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() })) + .returning(move |_| Ok(expected_response_dto.clone())); + + mock_users_service.expect_get_user_by_email() + .with(eq(user_email.clone())) + .returning(move |_| { + Ok(Some(UsersDetailItemDto { // Changed to UsersDetailItemDto + id: "existing_user_id".to_string(), + email: user_email.clone(), + fullname: "Existing User".to_string(), // Updated field + phone_number: "0987654321".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + })) + }); // Simulate existing user + + // Ensure create_user_by_dto is NOT called for existing user + mock_users_service.expect_create_user_by_dto().times(0); + + let app = setup_app_with_mocked_google_oauth_service( + GoogleOauthServiceImpl::with_services( + AuthServiceMock::new(), + mock_users_service, + &ENV, // Pass ENV here + ) + ).await; + + let request = Request::builder() + .uri("/google/callback?code=some_code&state=some_state") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(json_body.token.access_token, expected_access_token); + assert_eq!(json_body.token.refresh_token, expected_refresh_token); + assert_eq!(json_body.user.email, user_email); + } +} \ No newline at end of file diff --git a/tests/src/iam/auth/google/mod.rs b/tests/src/iam/auth/google/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/tests/src/lib.rs b/tests/src/lib.rs index ce7a91c..772fcde 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -21,13 +21,29 @@ pub fn create_test_user( id: make_thing("app_users", &Uuid::new_v4().to_string()), email: email.to_string(), fullname: format!("Randomize {} {}", fullname, rand::random::()), + legal_name: None, password: hash_password("secret").unwrap(), is_deleted: false, avatar: None, phone_number: "081234567890".to_string(), + phone_for_verification: None, is_active, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: make_thing("app_roles", role_id), created_at: get_iso_date(), updated_at: get_iso_date(), diff --git a/tests/src/mock_test.rs b/tests/src/mock_test.rs index ec85a06..b95db27 100644 --- a/tests/src/mock_test.rs +++ b/tests/src/mock_test.rs @@ -1,7 +1,7 @@ +// Restore only the necessary imports to fix unresolved function errors use crate::{get_iso_date, hash_password}; use imphnen_entities::AppState; use imphnen_iam::{PermissionsEnum, UsersSchema}; -use imphnen_libs::enviroment::load_env; use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use surrealdb::engine::{any, local}; @@ -29,23 +29,15 @@ struct RoleSeedData { } pub async fn create_mock_app_state() -> AppState { - load_env(); - - let db_ws = any::connect("ws://127.00.1:8000/rpc").await.unwrap(); - - let db_mem = Surreal::new::(()).await.unwrap(); - let unique_id = Uuid::new_v4().to_string(); let ns = format!("test_ns_{unique_id}"); let db = format!("test_db_{unique_id}"); - - db_ws .signin(Root { username: "root", @@ -54,11 +46,8 @@ pub async fn create_mock_app_state() -> AppState { .await .unwrap(); - - db_ws.use_ns(&ns).use_db(&db).await.unwrap(); - AppState { surrealdb_ws: db_ws, surrealdb_mem: db_mem, @@ -160,15 +149,31 @@ pub async fn seed_users_for_test( let user = UsersSchema { id: Thing::from(("app_users", id)), fullname: fullname.into(), + legal_name: None, email: email.into(), password: hash_password("password").unwrap(), avatar: None, phone_number: "081234567890".into(), + phone_for_verification: None, is_active: true, is_deleted: false, mentor_id: None, gender: None, birthdate: None, + domicile: None, + bio: None, + last_education: None, + linkedin_url: None, + github_url: None, + cv_url: None, + portfolio_url: None, + website_url: None, + twitter_url: None, + location: None, + skills: None, + experience: None, + education: None, + career_status: None, role: Thing::from(("app_roles", role_id)), created_at: get_iso_date(), updated_at: get_iso_date(), @@ -183,6 +188,7 @@ pub async fn seed_users_for_test( } pub async fn setup_all_test_environment() -> AppState { +debug!("Setting up all test environment in setup_all_test_environment()"); let app_state = create_mock_app_state().await; app_state .surrealdb_mem