From fe0912f2609ff8c3e77bd52cfe326cd8ba534b29 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Sun, 19 Oct 2025 00:51:40 +0200 Subject: [PATCH 01/23] protected branches --- .github/workflows/check-branch.yml | 14 ++++++++++++++ src/sql/import.sql | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/check-branch.yml diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml new file mode 100644 index 0000000..61054e1 --- /dev/null +++ b/.github/workflows/check-branch.yml @@ -0,0 +1,14 @@ +name: 'Check Source Branch' +on: + pull_request: + branches: + - main +jobs: + check-branch: + runs-on: ubuntu-latest + steps: + - name: Check if source branch is development + if: github.head_ref != 'development' + run: | + echo "ERROR: You can only merge to main from the development branch." + exit 1 \ No newline at end of file diff --git a/src/sql/import.sql b/src/sql/import.sql index 7714d89..0b1eafa 100644 --- a/src/sql/import.sql +++ b/src/sql/import.sql @@ -1,3 +1,4 @@ + create table chat_room_participant ( joined_at timestamp(6) with time zone not null, @@ -37,7 +38,7 @@ create table chat_room room_type varchar(255) not null constraint chat_room_room_type_check check ((room_type)::text = ANY ((ARRAY ['Single'::character varying, 'Group'::character varying])::text[])) - ); +); alter table chat_room owner to postgres; From e57e78af11bf3a967c49cfdd89749ec107eb7834 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Mon, 3 Nov 2025 23:44:56 +0100 Subject: [PATCH 02/23] App State Refactor --- .env | 3 +- Cargo.lock | 508 ++++++++++-------- Cargo.toml | 22 +- default.config.toml | 6 +- src/api/rooms.rs | 9 +- src/broadcast/notification.rs | 4 +- src/core/app_state.rs | 60 ++- src/core/config.rs | 13 +- src/core/mod.rs | 2 +- src/database/message_database.rs | 12 +- src/database/mod.rs | 8 +- .../{object_database.rs => object_storage.rs} | 20 +- src/lib.rs | 1 + src/main.rs | 53 +- src/model/message.rs | 8 +- src/model/mod.rs | 4 +- src/model/room.rs | 11 +- src/model/{user.rs => room_member.rs} | 3 +- src/repository/mod.rs | 3 + .../room_repository.rs} | 59 +- src/repository/user_repository.rs | 15 + src/repository/util.rs | 6 + src/sql/import.sql | 77 +-- 23 files changed, 508 insertions(+), 399 deletions(-) rename src/database/{object_database.rs => object_storage.rs} (84%) rename src/model/{user.rs => room_member.rs} (97%) create mode 100644 src/repository/mod.rs rename src/{database/room_database.rs => repository/room_repository.rs} (89%) create mode 100644 src/repository/user_repository.rs create mode 100644 src/repository/util.rs diff --git a/.env b/.env index 48f9a16..b47f083 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ DATABASE_URL=postgresql://postgres:meventure1234@localhost:32768/postgres -ISM_LOG_LEVEL=debug +ISM_LOG_LEVEL=info +ISM_USER_DB_CONFIG__DB_HOST=localhost \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2813f50..08d196e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.0" @@ -41,12 +32,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -192,9 +177,9 @@ dependencies = [ [[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", @@ -280,9 +265,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "bytes", @@ -300,8 +285,7 @@ dependencies = [ "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -315,9 +299,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", @@ -326,7 +310,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -347,21 +330,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.21.7" @@ -386,7 +354,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cexpr", "clang-sys", "itertools 0.12.1", @@ -409,12 +377,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" @@ -551,17 +513,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -657,9 +618,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.13" +version = "0.15.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1eb4fb07bc7f012422df02766c7bd5971effb894f573865642f06fa3265440" +checksum = "180e549344080374f9b32ed41bf3b6b57885ff6a289367b3dbc10eea8acc1918" dependencies = [ "async-trait", "convert_case", @@ -667,9 +628,10 @@ dependencies = [ "pathdiff", "ron", "rust-ini", - "serde", + "serde-untagged", + "serde_core", "serde_json", - "toml 0.9.2", + "toml 0.9.8", "winnow", "yaml-rust2", ] @@ -1083,6 +1045,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.10" @@ -1136,6 +1109,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1365,12 +1358,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.2" @@ -1596,7 +1583,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -1774,9 +1761,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", @@ -1784,6 +1771,7 @@ dependencies = [ "exr", "gif", "image-webp", + "moxcms", "num-traits", "png", "qoi", @@ -1859,17 +1847,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.6.0", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.10.1" @@ -1991,12 +1968,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - [[package]] name = "js-sys" version = "0.3.77" @@ -2165,7 +2136,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags", "libc", "redox_syscall", ] @@ -2211,9 +2182,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loop9" @@ -2235,11 +2206,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2353,6 +2324,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2436,12 +2417,11 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -2552,15 +2532,6 @@ dependencies = [ "libm", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.20.2" @@ -2573,7 +2544,7 @@ version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2621,12 +2592,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking" version = "2.2.1" @@ -2788,11 +2753,11 @@ checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "png" -version = "0.17.16" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -2867,6 +2832,15 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "qoi" version = "0.4.1" @@ -2894,7 +2868,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.0", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.9", "tokio", "tracing", @@ -2929,7 +2903,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -3088,7 +3062,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -3119,17 +3093,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "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", + "regex-automata", + "regex-syntax", ] [[package]] @@ -3140,15 +3105,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[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" @@ -3230,7 +3189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags 2.6.0", + "bitflags", "serde", "serde_derive", ] @@ -3276,21 +3235,14 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.21.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ "cfg-if", "ordered-multimap", - "trim-in-place", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3309,7 +3261,7 @@ version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3322,11 +3274,11 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3457,9 +3409,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scylla" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221bcc7d06d8eddb9f1152e7955c4965950a6b93666b40797a9ce78624f5a4d2" +checksum = "b42cf9feea170a110c5644c013a4dc790c24c60dc802f43df2ca69acae5112a4" dependencies = [ "arc-swap", "async-trait", @@ -3473,7 +3425,7 @@ dependencies = [ "rand_pcg", "scylla-cql", "smallvec", - "socket2", + "socket2 0.5.10", "thiserror 2.0.9", "tokio", "tracing", @@ -3482,9 +3434,9 @@ dependencies = [ [[package]] name = "scylla-cql" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b3e593a1cb468a39f7d51d6971b462a22672f22bc5b6b0dab5426acd48189a" +checksum = "5139a271deeb8b3b8118c28d19bb3519421ec4a131e3a39533503c526d415dc2" dependencies = [ "byteorder", "bytes", @@ -3502,9 +3454,9 @@ dependencies = [ [[package]] name = "scylla-macros" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd4e8ce08ba975bdbff47f6bc16f4a87f0c852866baaba5947e29f58e7ce4df" +checksum = "162aed3aa5b6985d121d9e7e4137efebd49645dee204962b2e9ab85176349119" dependencies = [ "darling", "proc-macro2", @@ -3518,7 +3470,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -3546,10 +3498,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -3563,11 +3516,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3576,14 +3550,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -3607,11 +3582,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3802,6 +3777,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -3942,7 +3927,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3986,7 +3971,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "chrono", "crc", @@ -4126,7 +4111,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -4236,13 +4221,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] @@ -4312,29 +4300,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2", + "socket2 0.6.1", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -4400,13 +4385,13 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "winnow", ] @@ -4422,11 +4407,11 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4444,9 +4429,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] @@ -4473,7 +4458,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.6.0", + "bitflags", "bytes", "futures-util", "http", @@ -4544,14 +4529,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4560,12 +4545,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "trim-in-place" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" - [[package]] name = "try-again" version = "0.2.2" @@ -4612,6 +4591,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.17.0" @@ -4706,9 +4691,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.1", "js-sys", @@ -4931,28 +4916,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.52.0" @@ -4968,13 +4931,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" dependencies = [ - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings", ] @@ -4985,7 +4954,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4994,7 +4963,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -5024,6 +4993,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5048,13 +5035,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5067,6 +5071,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5079,6 +5089,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5091,12 +5107,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5109,6 +5137,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5121,6 +5155,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5133,6 +5173,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5145,11 +5191,17 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -5160,7 +5212,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -5192,9 +5244,9 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818913695e83ece1f8d2a1c52d54484b7b46d0f9c06beeb2649b9da50d9b512d" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" dependencies = [ "arraydeque", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index 4425f20..436ba81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,26 +4,26 @@ version = "0.5.0" edition = "2024" [dependencies] -log = "0.4.27" -axum = { version = "0.8.4", features = ["multipart"] } -tokio = {version = "1.46.1", features = ["full"]} +log = "0.4.28" +axum = { version = "0.8.6", features = ["multipart"] } +tokio = {version = "1.48.0", features = ["full"]} tower = "0.5.2" -config = "0.15.13" -serde = "1.0.219" -scylla = { version = "1.3.0", features = ["chrono-04"] } +config = "0.15.18" +serde = "1.0.228" +scylla = { version = "1.3.1", features = ["chrono-04"] } futures = "0.3.31" -uuid = { version = "1.17.0", features = ["v4", "serde", "v7"] } -chrono = { version = "0.4.41", features = ["serde"] } +uuid = { version = "1.18.1", features = ["v4", "serde", "v7"] } +chrono = { version = "0.4.42", features = ["serde"] } tower-http = { version = "0.6.6", features = ["cors", "trace"] } tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } sqlx = {version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid", "macros"]} dotenv = "0.15.0" -serde_json = "1.0.140" +serde_json = "1.0.145" tokio-stream = { version = "0.1.17", features = ["sync"] } samsa = "0.1.7" minio = { version = "0.3.0", features = ["default"] } -image = { version = "0.25.6"} +image = { version = "0.25.8"} bytes = "1.10.1" diff --git a/default.config.toml b/default.config.toml index aa58b5c..fe48ff2 100644 --- a/default.config.toml +++ b/default.config.toml @@ -24,9 +24,9 @@ iss_realm = "meventure" valid_admin_client = "api-client" [object_db_config] -db_user = "minioadmin" -db_url = "http://localhost:9000" -db_password = "minioadmin" +access_key = "minioadmin" +storage_url = "http://localhost:9000" +secret_key = "minioadmin" bucket_name = "meventure" [kafka_config] diff --git a/src/api/rooms.rs b/src/api/rooms.rs index 6998e07..91c8cec 100644 --- a/src/api/rooms.rs +++ b/src/api/rooms.rs @@ -10,7 +10,7 @@ use bytes::Bytes; use crate::api::errors::{ErrorCode, HttpError}; use crate::api::timeline::{msg_to_dto}; use crate::keycloak::decode::KeycloakToken; -use crate::model::{ChatRoomWithUserDTO, MembershipStatus, Message, MessageBody, NewRoom as UploadRoom, RoomType, RoomChangeBody, ChatRoomEntity, User, UploadResponse, SingleRoomSearchUserParams}; +use crate::model::{ChatRoomWithUserDTO, MembershipStatus, Message, MessageBody, NewRoom as UploadRoom, RoomType, RoomChangeBody, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; use crate::api::utils::{check_user_in_room, crop_image_from_center, parse_uuid}; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; @@ -66,6 +66,9 @@ pub async fn get_room_with_details( room_name: chat_room.room_name.unwrap_or(String::from("Unnamed Chat")), room_image_url: chat_room.room_image_url, created_at: chat_room.created_at, + latest_message: chat_room.latest_message, + unread: chat_room.unread, + latest_message_preview_text: chat_room.latest_message_preview_text, users: users, }; Json(room_details).into_response() @@ -229,7 +232,7 @@ pub async fn leave_room( } } -async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Response { +async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Response { if let Err(err) = state.message_repository.clear_chat_room_messages(&room.id).await { error!("Can't clear chat messages for this room: {}", err); return HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to delete this room.").into_response(); @@ -251,7 +254,7 @@ async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, u StatusCode::OK.into_response() } -async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: User) -> Response { +async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: RoomMember) -> Response { let mut tx = state.room_repository.start_transaction().await.unwrap(); if let Err(err) = state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user).await { error!("{}", err.to_string()); diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index 9090be0..cd720d5 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::{ChatRoomListItemDTO, MessageDTO}; +use crate::model::{ChatRoom, MessageDTO}; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -35,7 +35,7 @@ pub enum NotificationEvent { /** * Sending this event to a newly invited user */ - NewRoom {room: ChatRoomListItemDTO}, + NewRoom {room: ChatRoom }, /** * Sending this event to a user who has left a room diff --git a/src/core/app_state.rs b/src/core/app_state.rs index 029bd24..3a6fc1f 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -1,10 +1,62 @@ +use log::info; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use tokio::task; use crate::core::ISMConfig; -use crate::database::{MessageDatabase, ObjectDatabase, RoomDatabase}; +use crate::database::{MessageDatabase, ObjectStorage}; +use crate::kafka::start_consumer; +use crate::repository::room_repository::RoomRepository; +use crate::repository::user_repository::UserRepository; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AppState { pub env: ISMConfig, - pub room_repository: RoomDatabase, + pub room_repository: RoomRepository, + pub user_repository: UserRepository, pub message_repository: MessageDatabase, - pub s3_bucket: ObjectDatabase + pub s3_bucket: ObjectStorage +} + +impl AppState { + pub async fn new(config: ISMConfig) -> Self { + + //1: setting up the postgre sql connection for all repositories: + let options = PgConnectOptions::new() + .host(&config.user_db_config.db_host) + .port(config.user_db_config.db_port) + .database(&config.user_db_config.db_name) + .username(&config.user_db_config.db_user) + .password(&config.user_db_config.db_password); + let pool = match PgPoolOptions::new() + .max_connections(8) + .connect_with(options) + .await + { + Ok(pool) => { + info!("Established connection to the room database."); + pool + } + Err(err) => { + panic!("Failed to connect to the room database: {:?}", err); + } + }; + + //2. State struct: + let state = Self { + env: config.clone(), + room_repository: RoomRepository::new(pool.clone()), + user_repository: UserRepository::new(pool.clone()), + message_repository: MessageDatabase::new(&config.message_db_config).await, + s3_bucket: ObjectStorage::new(&config.object_db_config).await + }; + + //3: kafka (optional) + if state.env.use_kafka == true { + let kafka_config = state.env.kafka_config.clone(); + task::spawn(async move { + start_consumer(kafka_config).await; + }); + } + + state + } } \ No newline at end of file diff --git a/src/core/config.rs b/src/core/config.rs index 269da96..24c841d 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -11,17 +11,17 @@ pub struct ISMConfig { pub log_level: String, pub cors_origin: String, pub user_db_config: UserDbConfig, - pub object_db_config: ObjectDbConfig, + pub object_db_config: ObjectStorageConfig, pub message_db_config: MessageDbConfig, pub token_issuer: TokenIssuer, pub kafka_config: KafkaConfig } #[derive(Deserialize, Debug, Clone)] -pub struct ObjectDbConfig { - pub db_user: String, - pub db_url: String, - pub db_password: String, +pub struct ObjectStorageConfig { + pub access_key: String, + pub storage_url: String, + pub secret_key: String, pub bucket_name: String } @@ -68,8 +68,9 @@ impl ISMConfig { let config = Config::builder() .add_source(File::with_name("default.config.toml")) .add_source(File::with_name(&format!("{mode}.config.toml")).required(false)) - .add_source(Environment::default().separator("__")) + .add_source(Environment::with_prefix("ism").prefix_separator("_").separator("__")) .build()?; + config.try_deserialize() } } \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs index 39cf265..0539de3 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,5 +1,5 @@ mod config; mod app_state; -pub use config::{ISMConfig, UserDbConfig, MessageDbConfig, ObjectDbConfig, TokenIssuer, KafkaConfig}; +pub use config::{ISMConfig, UserDbConfig, MessageDbConfig, ObjectStorageConfig, TokenIssuer, KafkaConfig}; pub use app_state::*; \ No newline at end of file diff --git a/src/database/message_database.rs b/src/database/message_database.rs index 30a3dcb..8200c67 100644 --- a/src/database/message_database.rs +++ b/src/database/message_database.rs @@ -75,12 +75,12 @@ impl MessageDatabase { let queries = [ "CREATE KEYSPACE IF NOT EXISTS messaging WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'replication_factor' : 1}", "CREATE TABLE IF NOT EXISTS messaging.chat_messages ( - chat_room_id UUID, - message_id UUID, - sender_id UUID, - msg_body TEXT, - msg_type TEXT, - created_at TIMESTAMP, + chat_room_id UUID, + message_id UUID, + sender_id UUID, + msg_body TEXT, + msg_type TEXT, + created_at TIMESTAMP, PRIMARY KEY ((chat_room_id), created_at, message_id) )" ]; diff --git a/src/database/mod.rs b/src/database/mod.rs index 914ce40..945d16a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,7 +1,5 @@ mod message_database; -mod room_database; -mod object_database; +mod object_storage; -pub use message_database::{MessageDatabase}; -pub use room_database::{RoomDatabase}; -pub use object_database::{ObjectDatabase}; +pub use message_database::MessageDatabase; +pub use object_storage::ObjectStorage; diff --git a/src/database/object_database.rs b/src/database/object_storage.rs similarity index 84% rename from src/database/object_database.rs rename to src/database/object_storage.rs index 118ea00..995b5fe 100644 --- a/src/database/object_database.rs +++ b/src/database/object_storage.rs @@ -7,25 +7,25 @@ use minio::s3::creds::StaticProvider; use minio::s3::http::BaseUrl; use minio::s3::segmented_bytes::SegmentedBytes; use minio::s3::types::S3Api; -use crate::core::ObjectDbConfig; +use crate::core::ObjectStorageConfig; #[derive(Debug, Clone)] -pub struct ObjectDatabase { +pub struct ObjectStorage { session: Arc, - config: ObjectDbConfig, + config: ObjectStorageConfig, } -impl ObjectDatabase { +impl ObjectStorage { - pub async fn new(config: &ObjectDbConfig) -> Self { + pub async fn new(config: &ObjectStorageConfig) -> Self { let static_provider = Box::new(StaticProvider::new( - &config.db_user, - &config.db_password, + &config.access_key, + &config.secret_key, None, )); - let url = match config.db_url.parse::() { + let url = match config.storage_url.parse::() { Ok(url) => url, - Err(error) => panic!("Unable to parse db url: {:?}", error) + Err(error) => panic!("Unable to parse s3 url: {:?}", error) }; let client: Client = match ClientBuilder::new(url).provider(Some(static_provider)).build() { Ok(client) => client, @@ -42,7 +42,7 @@ impl ObjectDatabase { panic!("Unable to check if bucket exists: {:?}", error) } }; - ObjectDatabase { session: Arc::new(client), config: config.clone() } + ObjectStorage { session: Arc::new(client), config: config.clone() } } pub async fn get_object(&self, object_id: &String) -> Result> { diff --git a/src/lib.rs b/src/lib.rs index a8bac0e..79e4444 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,4 @@ pub mod model; pub mod broadcast; pub mod kafka; pub mod keycloak; +pub mod repository; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 8431246..6113832 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,51 +1,26 @@ use std::env; use dotenv::dotenv; use tokio::net::TcpListener; -use tokio::{signal, task}; +use tokio::{signal}; use tracing::info; use tracing_subscriber::EnvFilter; use ism::core::{AppState, ISMConfig}; use ism::api::{init_router}; -use ism::database::{MessageDatabase, ObjectDatabase, RoomDatabase}; use tracing_subscriber::filter::LevelFilter; use ism::broadcast::BroadcastChannel; -use ism::kafka::start_consumer; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads //https://github.com/AarambhDevHub/rust-backend-axum #[tokio::main(flavor = "multi_thread")] async fn main() { - dotenv().ok(); - let run_mode = env::var("ISM_MODE").unwrap_or_else(|_| "development".into()); - let config = ISMConfig::new(&run_mode).unwrap_or_else(|err| panic!("Missing needed env: {}", err)); - - let filter = EnvFilter::try_from_env("ISM_LOG_LEVEL").unwrap() - .add_directive(LevelFilter::INFO.into()) - .add_directive("scylla=info".parse().unwrap()); - - tracing_subscriber::fmt() - .with_env_filter(filter) - .init(); + let config = init_configuration(); - info!("Starting up ISM in {run_mode} mode."); //init broadcaster channel BroadcastChannel::init().await; - //init app state and both database connections, exit application if failing - let app_state = AppState { - env: config.clone(), - room_repository: RoomDatabase::new(&config.user_db_config).await, - message_repository: MessageDatabase::new(&config.message_db_config).await, - s3_bucket: ObjectDatabase::new(&config.object_db_config).await - }; - - if app_state.env.use_kafka == true { - let kafka_config = app_state.env.kafka_config.clone(); - task::spawn(async move { - start_consumer(kafka_config).await; - }); - } + //init the app state including database connections, kafka etc. + let app_state = AppState::new(config.clone()).await; //init api router: let app = init_router(app_state).await; @@ -81,4 +56,24 @@ async fn shutdown_signal() { _ = ctrl_c => {}, _ = terminate => {}, } +} + +fn init_configuration() -> ISMConfig { + dotenv().ok(); + let run_mode = env::var("ISM_MODE").unwrap_or_else(|_| "development".into()); + let config = ISMConfig::new(&run_mode).unwrap_or_else(|err| panic!("Missing needed env: {}", err)); + + let filter = EnvFilter::builder() + .with_env_var("ISM_LOG_LEVEL") + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy() + .add_directive("scylla=info".parse().unwrap()); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .init(); + + info!("Starting up ISM in {run_mode} mode."); + + config } \ No newline at end of file diff --git a/src/model/message.rs b/src/model/message.rs index 470e2ec..209b426 100644 --- a/src/model/message.rs +++ b/src/model/message.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::User; +use crate::model::RoomMember; #[derive(Debug, Deserialize, Serialize, Clone)] pub enum MsgType { @@ -122,9 +122,9 @@ pub enum RepliedMessageDetails { #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(tag = "type")] pub enum RoomChangeBody { - UserJoined {related_user: User}, - UserLeft {related_user: User}, - UserInvited {related_user: User} + UserJoined {related_user: RoomMember }, + UserLeft {related_user: RoomMember }, + UserInvited {related_user: RoomMember } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 389da2f..6dc3468 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,11 +1,11 @@ mod room; mod message; -pub mod user; +pub mod room_member; mod response_utils; mod queries; -pub use user::*; +pub use room_member::*; pub use room::*; pub use message::*; pub use response_utils::*; diff --git a/src/model/room.rs b/src/model/room.rs index 9004ef6..503fd4a 100644 --- a/src/model/room.rs +++ b/src/model/room.rs @@ -2,7 +2,7 @@ use chrono::prelude::*; use serde::{Deserialize, Serialize}; use sqlx::Type; use uuid::Uuid; -use crate::model::user::User; +use crate::model::room_member::RoomMember; #[derive(sqlx::FromRow, sqlx::Type, Debug)] pub struct ChatRoomEntity { @@ -53,15 +53,18 @@ impl RoomType { pub struct ChatRoomWithUserDTO { pub id: Uuid, pub room_type: RoomType, - pub room_name: String, pub room_image_url: Option, + pub room_name: String, pub created_at: DateTime, - pub users: Vec + pub latest_message: Option>, + pub unread: Option, + pub latest_message_preview_text: Option, + pub users: Vec } #[derive(Debug, Deserialize, Serialize, sqlx::FromRow, sqlx::Type, Clone)] #[serde(rename_all = "camelCase")] -pub struct ChatRoomListItemDTO { +pub struct ChatRoom { pub id: Uuid, pub room_type: RoomType, pub room_image_url: Option, diff --git a/src/model/user.rs b/src/model/room_member.rs similarity index 97% rename from src/model/user.rs rename to src/model/room_member.rs index 1d788ba..5f8ee0c 100644 --- a/src/model/user.rs +++ b/src/model/room_member.rs @@ -5,7 +5,7 @@ use uuid::Uuid; #[derive(Debug, Deserialize, Serialize, sqlx::FromRow, sqlx::Type, Clone)] #[serde(rename_all = "camelCase")] -pub struct User { +pub struct RoomMember { pub id: Uuid, pub display_name: String, pub profile_picture: Option, @@ -24,7 +24,6 @@ pub enum MembershipStatus { impl MembershipStatus { - pub fn to_str(&self) -> &str { match self { MembershipStatus::Joined => "Joined", diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000..b82a084 --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,3 @@ +pub mod room_repository; +pub mod user_repository; +mod util; \ No newline at end of file diff --git a/src/database/room_database.rs b/src/repository/room_repository.rs similarity index 89% rename from src/database/room_database.rs rename to src/repository/room_repository.rs index 1e105da..4018c3a 100644 --- a/src/database/room_database.rs +++ b/src/repository/room_repository.rs @@ -1,41 +1,20 @@ use chrono::Utc; -use log::{info}; use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; use sqlx::error::BoxDynError; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use uuid::Uuid; -use crate::core::{UserDbConfig}; -use crate::model::user::{User, MembershipStatus}; -use crate::model::{ChatRoomEntity, ChatRoomListItemDTO, NewRoom, RoomType}; +use crate::model::room_member::{RoomMember, MembershipStatus}; +use crate::model::{ChatRoomEntity, ChatRoom, NewRoom, RoomType}; -#[derive(Debug, Clone)] -pub struct RoomDatabase { +#[derive(Clone)] +pub struct RoomRepository { pool: Pool, } -impl RoomDatabase { +impl RoomRepository { - pub async fn new(config: &UserDbConfig) -> Self { - let opt = PgConnectOptions::new() - .host(&config.db_host) - .port(config.db_port) - .database(&config.db_name) - .username(&config.db_user) - .password(&config.db_password); - let pool = match PgPoolOptions::new() - .max_connections(25) - .connect_with(opt) - .await - { - Ok(pool) => { - info!("Established connection to the room database."); - pool - } - Err(err) => { - panic!("Failed to connect to the room database: {:?}", err); - } - }; - RoomDatabase { pool } + + pub fn new(pool: Pool) -> Self { + RoomRepository { pool } } pub async fn start_transaction(&self) -> Result, Error> { @@ -47,8 +26,8 @@ impl RoomDatabase { &self.pool } - pub async fn select_all_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { - let users = sqlx::query_as!(User, + pub async fn select_all_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { + let users = sqlx::query_as!(RoomMember, r#" SELECT users.id, users.display_name, @@ -63,8 +42,8 @@ impl RoomDatabase { Ok(users) } - pub async fn select_joined_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { - let users = sqlx::query_as!(User, + pub async fn select_joined_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { + let users = sqlx::query_as!(RoomMember, r#" SELECT users.id, @@ -80,9 +59,9 @@ impl RoomDatabase { Ok(users) } - pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { + pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { let rooms = sqlx::query_as!( - ChatRoomListItemDTO, + ChatRoom, r#" WITH room_selection AS ( SELECT DISTINCT ON (room.id) @@ -123,9 +102,9 @@ impl RoomDatabase { Ok(()) } - pub async fn find_specific_joined_room(&self, room_id: &Uuid, user_id: &Uuid) -> Result, sqlx::Error> { + pub async fn find_specific_joined_room(&self, room_id: &Uuid, user_id: &Uuid) -> Result, sqlx::Error> { let room = sqlx::query_as!( - ChatRoomListItemDTO, + ChatRoom, r#" SELECT room.id, @@ -241,12 +220,12 @@ impl RoomDatabase { } } - pub async fn add_user_to_room(&self, user_id: &Uuid, room_id: &Uuid) -> Result { + pub async fn add_user_to_room(&self, user_id: &Uuid, room_id: &Uuid) -> Result { let mut tx = self.pool.begin().await?; sqlx::query!("INSERT INTO chat_room_participant (user_id, room_id, joined_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, room_id) DO UPDATE SET joined_at = $3, participant_state = 'Joined'", user_id, room_id, Utc::now()).execute(&mut *tx).await?; - let user = sqlx::query_as!(User, + let user = sqlx::query_as!(RoomMember, r#" SELECT users.id, @@ -316,7 +295,7 @@ impl RoomDatabase { } - pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user: &User) -> Result<(), sqlx::Error> { + pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user: &RoomMember) -> Result<(), sqlx::Error> { sqlx::query!("UPDATE chat_room_participant SET participant_state = 'Left' WHERE user_id = $1 AND room_id = $2", user.id, room_id).execute(&mut *conn).await?; let text = format!("{}{}", user.display_name, String::from(" hat den Chat verlassen.")); //todo: think about a better latest msg logic sqlx::query!("UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, text).execute(&mut *conn).await?; diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs new file mode 100644 index 0000000..e47f819 --- /dev/null +++ b/src/repository/user_repository.rs @@ -0,0 +1,15 @@ +use sqlx::{Pool, Postgres}; + + +#[derive(Clone)] +pub struct UserRepository { + pool: Pool, +} + +impl UserRepository { + + pub fn new(pool: Pool) -> Self { + UserRepository { pool } + } + +} \ No newline at end of file diff --git a/src/repository/util.rs b/src/repository/util.rs new file mode 100644 index 0000000..9a69a9a --- /dev/null +++ b/src/repository/util.rs @@ -0,0 +1,6 @@ +use sqlx::{Error, Pool, Postgres, Transaction}; + +pub async fn start_transaction(pool: &Pool) -> Result, Error> { + let tx = pool.begin().await?; + Ok(tx) +} \ No newline at end of file diff --git a/src/sql/import.sql b/src/sql/import.sql index 0b1eafa..aa62f37 100644 --- a/src/sql/import.sql +++ b/src/sql/import.sql @@ -1,31 +1,29 @@ - -create table chat_room_participant +-- 1. Create app_user (no dependencies) +create table app_user ( - joined_at timestamp(6) with time zone not null, - last_message_read_at timestamp(6) with time zone, - participant_state varchar(255) not null - constraint chat_room_participant_participant_state_check - check ((participant_state)::text = ANY - ((ARRAY ['Joined'::character varying, 'Invited'::character varying, 'Left'::character varying])::text[])), - room_id uuid not null - constraint fk677gcppc5fneuseoige64fsnm - references chat_room, - user_id uuid not null - constraint fkdjp8ps7q8cjcitu5e8fgkhxq0 - references app_user, - primary key (room_id, user_id) + id uuid not null primary key, + created_at timestamp(6) with time zone not null, + deleted_at timestamp(6) with time zone, + description varchar(250), + display_name varchar(255) not null, + friends_count bigint not null, + last_modified_at timestamp(6) with time zone, + profile_picture varchar(255), + raw_name varchar(255) ); -alter table chat_room_participant +alter table app_user owner to postgres; -create index idx_participants_user_room_id - on chat_room_participant (user_id, room_id); +create index user_rawname + on app_user (raw_name); -create index idx_participants_room_id_membership - on chat_room_participant (room_id, participant_state); +create unique index idx_unique_displayname_if_not_deleted + on app_user (display_name) + where (deleted_at IS NULL); +-- 2. Create chat_room (no dependencies) create table chat_room ( id uuid not null @@ -38,7 +36,7 @@ create table chat_room room_type varchar(255) not null constraint chat_room_room_type_check check ((room_type)::text = ANY ((ARRAY ['Single'::character varying, 'Group'::character varying])::text[])) -); + ); alter table chat_room owner to postgres; @@ -50,26 +48,29 @@ create index idx_room_latest_message on chat_room (latest_message); -create table app_user +-- 3. Create chat_room_participant (depends on app_user and chat_room) +create table chat_room_participant ( - id uuid not null primary key, - created_at timestamp(6) with time zone not null, - deleted_at timestamp(6) with time zone, - description varchar(250), - display_name varchar(255) not null, - friends_count bigint not null, - last_modified_at timestamp(6) with time zone, - profile_picture varchar(255), - raw_name varchar(255), + joined_at timestamp(6) with time zone not null, + last_message_read_at timestamp(6) with time zone, + participant_state varchar(255) not null + constraint chat_room_participant_participant_state_check + check ((participant_state)::text = ANY + ((ARRAY ['Joined'::character varying, 'Invited'::character varying, 'Left'::character varying])::text[])), + room_id uuid not null + constraint fk677gcppc5fneuseoige64fsnm + references chat_room, + user_id uuid not null + constraint fkdjp8ps7q8cjcitu5e8fgkhxq0 + references app_user, + primary key (room_id, user_id) ); -alter table app_user +alter table chat_room_participant owner to postgres; -create index user_rawname - on app_user (raw_name); - -create unique index idx_unique_displayname_if_not_deleted - on app_user (display_name) - where (deleted_at IS NULL); +create index idx_participants_user_room_id + on chat_room_participant (user_id, room_id); +create index idx_participants_room_id_membership + on chat_room_participant (room_id, participant_state); From b9ebc20a94b62e8f903dee075f3c2629048c3c64 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 00:33:57 +0100 Subject: [PATCH 03/23] Update dependencies, update tu rust 1.91 --- .idea/vcs.xml | 8 +++++ .idea/workspace.xml | 58 ++++++++++++++++++++++--------- Cargo.lock | 56 +++++++++++++++++------------ Cargo.toml | 19 +++++----- Dockerfile | 4 +-- src/api/messages.rs | 6 ++-- src/api/notifications.rs | 4 +-- src/api/rooms.rs | 39 ++++++++------------- src/api/timeline.rs | 6 ++-- src/api/utils.rs | 4 --- src/keycloak/decode.rs | 11 ++++-- src/repository/room_repository.rs | 2 +- src/repository/user_repository.rs | 7 +++- src/repository/util.rs | 5 --- 14 files changed, 130 insertions(+), 99 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..d5b23a5 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,5 +1,13 @@ + + + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index afaf6bc..09a0c35 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -11,9 +11,21 @@ - - + + + + + + + + + + + + + + { "lastFilter": { @@ -82,7 +95,7 @@ "RunOnceActivity.rust.reset.selective.auto.import": "true", "git-widget-placeholder": "master", "ignore.virus.scanning.warn.message": "true", - "last_opened_file_path": "C:/Users/Tim/IdeaProjects/ISM/src/broadcast", + "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", "node.js.detected.package.eslint": "true", "node.js.detected.package.tslint": "true", "node.js.selected.package.eslint": "(autodetect)", @@ -91,7 +104,7 @@ "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", "org.rust.first.attach.projects": "true", - "settings.editor.selected.configurable": "configurable.group.appearance", + "settings.editor.selected.configurable": "language.rust.cargo.check", "vue.rearranger.settings.migration": "true" }, "keyToStringList": { @@ -102,14 +115,15 @@ } + + - @@ -197,6 +211,13 @@ + + + + + + + @@ -366,14 +387,9 @@ - - - - @@ -780,8 +804,6 @@ - - @@ -805,7 +827,9 @@ - diff --git a/Cargo.lock b/Cargo.lock index 08d196e..56f411f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,9 +134,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "assertr" -version = "0.1.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb2563193472366adb562d419007007251b132685bccdbafc09dcd082b991a1" +checksum = "d8af1e6d90e4ec2f8252a47b1998af731ea49b4a5903732119baeb0b016472a4" dependencies = [ "indoc", "num", @@ -247,6 +247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1200,9 +1201,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1740,9 +1741,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1991,16 +1992,18 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" dependencies = [ + "aws-lc-rs", "base64 0.22.1", + "getrandom 0.2.15", "js-sys", "pem", - "ring", "serde", "serde_json", + "signature", "simple_asn1", ] @@ -2405,9 +2408,9 @@ dependencies = [ [[package]] name = "nonempty" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" [[package]] name = "noop_proc_macro" @@ -2663,9 +2666,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -3116,9 +3119,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -3178,7 +3181,7 @@ dependencies = [ "getrandom 0.2.15", "libc", "spin", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3324,7 +3327,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4573,18 +4576,18 @@ dependencies = [ [[package]] name = "typed-builder" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63bcaf7e9806c206f7d7b9c1f38e0dce8bb165a80af0898161058b19248534" +checksum = "0d0dd654273fc253fde1df4172c31fb6615cf8b041d3a4008a028ef8b1119e66" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d8d828da2a3d759d3519cdf29a5bac49c77d039ad36d0782edadbf9cd5415b" +checksum = "016c26257f448222014296978b2c8456e2cad4de308c35bdb1e383acd569ef5b" dependencies = [ "proc-macro2", "quote", @@ -4648,6 +4651,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -4656,13 +4665,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 436ba81..04aac35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,21 +26,20 @@ minio = { version = "0.3.0", features = ["default"] } image = { version = "0.25.8"} bytes = "1.10.1" - #keycloak: atomic-time = "0.1.5" educe = { version = "0.6.0", default-features = false, features = ["Debug"] } http = "1.3.1" -jsonwebtoken = "9.3.1" -nonempty = { version = "0.11.0", features = ["std"] } -reqwest = { version = "0.12.22", features = ["json"], default-features = false } +jsonwebtoken = { version = "10.1.0", features = ["aws_lc_rs"] } +nonempty = { version = "0.12.0", features = ["std"] } +reqwest = { version = "0.12.24", features = ["json"], default-features = false } serde-querystring = "0.3.0" serde_with = "3.14.0" snafu = "0.8.6" time = "0.3.41" try-again = "0.2.2" -typed-builder = "0.21.0" -url = "2.5.4" +typed-builder = "0.23.0" +url = "2.5.7" [features] default = ["default-tls", "reqwest/charset", "reqwest/http2", "reqwest/macos-system-configuration"] @@ -49,8 +48,8 @@ rustls-tls = ["reqwest/rustls-tls"] [dev-dependencies] -assertr = "0.1.0" -tower-http = { version = "0.6.2", features = ["trace"] } -tracing-subscriber = "0.3.19" -uuid = { version = "1.17.0", features = ["v7", "serde"] } +assertr = "0.3.9" +tower-http = { version = "0.6.6", features = ["trace"] } +tracing-subscriber = "0.3.20" +uuid = { version = "1.18.1", features = ["v7", "serde"] } sqlx-cli = { version = "0.8.6", features = ["postgres", "rustls"] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 476af8f..962b472 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.86.0-slim-bookworm AS builder +FROM rust:1.91.0-slim-bookworm AS builder WORKDIR /app @@ -6,9 +6,9 @@ COPY .sqlx ./.sqlx/ COPY Cargo.toml Cargo.lock ./ COPY src ./src -# Installiere OpenSSL-Entwicklungspakete ENV SQLX_OFFLINE=true +# Installiere OpenSSL-Entwicklungspakete RUN apt-get update && apt-get install -y --no-install-recommends libssl-dev pkg-config # Baue Abhängigkeiten diff --git a/src/api/messages.rs b/src/api/messages.rs index ae51b04..b1bd2b3 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -9,7 +9,6 @@ use log::error; use uuid::Uuid; use crate::api::errors::{ErrorCode, HttpError}; use crate::api::timeline::msg_to_dto; -use crate::api::utils::parse_uuid; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::ChatMessage; use crate::core::AppState; @@ -22,7 +21,6 @@ pub async fn send_message( State(state): State>, Json(payload): Json ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); //validate if the user is in the room let users = match state.room_repository.select_room_participants_ids(&payload.chat_room_id).await { Ok(ids) => ids, @@ -31,7 +29,7 @@ pub async fn send_message( return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't fetch room participants.").into_response(); } }; - if !users.contains(&id) { + if !users.contains(&token.subject) { return HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions, "Room not found or access denied.").into_response(); } @@ -55,7 +53,7 @@ pub async fn send_message( } }; - let msg = match Message::new(payload.chat_room_id, id, msg_body) { + let msg = match Message::new(payload.chat_room_id, token.subject, msg_body) { Ok(message) => message, Err(err) => { error!("{}", err.to_string()); diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8100493..f60b3f7 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -10,7 +10,6 @@ use log::error; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use crate::api::errors::{ErrorCode, HttpError}; -use crate::api::utils::parse_uuid; use crate::broadcast::{BroadcastChannel, SendNotification}; use crate::core::AppState; use crate::keycloak::decode::KeycloakToken; @@ -21,9 +20,8 @@ pub async fn stream_server_events( ) -> Sse>> { use futures::StreamExt; - let id = parse_uuid(&token.subject).unwrap(); - let receiver = BroadcastChannel::get().subscribe_to_user_events(id.clone()).await; + let receiver = BroadcastChannel::get().subscribe_to_user_events(token.subject.clone()).await; let stream = BroadcastStream::new(receiver).filter_map(move |notification| async move { match notification { diff --git a/src/api/rooms.rs b/src/api/rooms.rs index 91c8cec..52dde85 100644 --- a/src/api/rooms.rs +++ b/src/api/rooms.rs @@ -11,7 +11,7 @@ use crate::api::errors::{ErrorCode, HttpError}; use crate::api::timeline::{msg_to_dto}; use crate::keycloak::decode::KeycloakToken; use crate::model::{ChatRoomWithUserDTO, MembershipStatus, Message, MessageBody, NewRoom as UploadRoom, RoomType, RoomChangeBody, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; -use crate::api::utils::{check_user_in_room, crop_image_from_center, parse_uuid}; +use crate::api::utils::{check_user_in_room, crop_image_from_center}; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; use crate::core::AppState; @@ -31,8 +31,7 @@ pub async fn get_joined_rooms( State(state): State>, Extension(token): Extension>, ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.get_joined_rooms(&id).await { + match state.room_repository.get_joined_rooms(&token.subject).await { Ok(rooms) => Json(rooms).into_response(), Err(err) => HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::UnexpectedError, err.to_string()).into_response() } @@ -43,13 +42,12 @@ pub async fn get_room_with_details( Extension(token): Extension>, Path(room_id): Path ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &room_id).await { + if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { return err.into_response(); } let res = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_id, &id), + state.room_repository.find_specific_joined_room(&room_id, &token.subject), state.room_repository.select_all_user_in_room(&room_id) ); @@ -85,9 +83,8 @@ pub async fn mark_room_as_read( Extension(token): Extension>, Path(room_id): Path ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); let pl = state.room_repository.get_connection(); - match state.room_repository.update_user_read_status(pl, &room_id, &id).await { + match state.room_repository.update_user_read_status(pl, &room_id, &token.subject).await { Ok(()) => StatusCode::OK.into_response(), Err(_) => HttpError::bad_request(ErrorCode::UnexpectedError,"Can't update user read status.").into_response() } @@ -99,9 +96,8 @@ pub async fn create_room( State(state): State>, Json(payload): Json ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - if !payload.invited_users.contains(&id) { + if !payload.invited_users.contains(&token.subject) { return HttpError::bad_request(ErrorCode::InvalidContent, "Sender ID is not in the list of invited users.").into_response(); } @@ -129,14 +125,14 @@ pub async fn create_room( let users = payload.invited_users; if room_entity.room_type == RoomType::Single { - let other_user = match users.iter().find(|&&entry| entry != id) { + let other_user = match users.iter().find(|&&entry| entry != token.subject) { Some(other_user) => other_user, None => return HttpError::bad_request(ErrorCode::InvalidContent,"Can't find other user.").into_response(), }; //sending 2 specific room views to the users, because private rooms are shown like another user let result = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_entity.id, &id), + state.room_repository.find_specific_joined_room(&room_entity.id, &token.subject), state.room_repository.find_specific_joined_room(&room_entity.id, other_user) ); match result { @@ -152,7 +148,7 @@ pub async fn create_room( broadcast.send_event(Notification { body: NewRoom {room: creator_dto.clone()}, created_at: Utc::now() - }, &id).await; + }, &token.subject).await; Json(creator_dto).into_response() } else { @@ -167,7 +163,7 @@ pub async fn create_room( } else { //is group room - let room = match state.room_repository.find_specific_joined_room(&room_entity.id, &id).await { + let room = match state.room_repository.find_specific_joined_room(&room_entity.id, &token.subject).await { Ok(Some(room)) => room, Ok(None) => return HttpError::bad_request(ErrorCode::UnexpectedError,"Room not found after creation.").into_response(), Err(error) => { @@ -193,8 +189,7 @@ pub async fn get_room_list_item_by_id( State(state): State>, Path(room_id): Path ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.find_specific_joined_room(&room_id, &id).await { + match state.room_repository.find_specific_joined_room(&room_id, &token.subject).await { Ok(Some(room)) => Json(room).into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(err) => HttpError::bad_request(ErrorCode::UnexpectedError, err.to_string()).into_response() @@ -207,7 +202,6 @@ pub async fn leave_room( State(state): State>, Path(room_id): Path ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); let result = tokio::try_join!( //executing 2 queries async state.room_repository.select_room(&room_id), state.room_repository.select_joined_user_in_room(&room_id) @@ -219,7 +213,7 @@ pub async fn leave_room( return HttpError::bad_request(ErrorCode::InvalidContent,"Can't get room & user state.").into_response() } }; - let leaving_user = match users.iter().find(|user| user.id == id) { + let leaving_user = match users.iter().find(|user| user.id == token.subject) { Some(user) => {user.clone()} None => { return HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions,"User not found in this room.").into_response(); @@ -323,7 +317,6 @@ pub async fn invite_to_room( Path((room_id, user_id)): Path<(Uuid, Uuid)> ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); let result = tokio::try_join!( //executing 2 queries async state.room_repository.select_room(&room_id), state.room_repository.select_joined_user_in_room(&room_id) @@ -339,7 +332,7 @@ pub async fn invite_to_room( return HttpError::bad_request(ErrorCode::InvalidContent, "Room type single doesn't allow invites!").into_response(); } //we have to check if the inviter is in the room and the invited user isn't! - let user_to_find = users.iter().find(|user| user.id == id); + let user_to_find = users.iter().find(|user| user.id == token.subject); let user_to_exclude = users.iter().find(|user| user.id == user_id); match (user_to_find, user_to_exclude) { (Some(_inviter), None) => {} //we have checked the invite rules and continue @@ -417,8 +410,7 @@ pub async fn search_existing_single_room( State(state): State>, Query(params): Query, ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.find_room_between_users(&id, ¶ms.with_user).await { + match state.room_repository.find_room_between_users(&token.subject, ¶ms.with_user).await { Ok(Some(room)) => (StatusCode::OK, room.to_string()).into_response(), Ok(None) => StatusCode::NO_CONTENT.into_response(), Err(e) => { @@ -434,8 +426,7 @@ pub async fn save_room_image( Path(room_id): Path, mut multipart: Multipart ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &room_id).await { + if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { return err.into_response(); } diff --git a/src/api/timeline.rs b/src/api/timeline.rs index b33f160..a72f8be 100644 --- a/src/api/timeline.rs +++ b/src/api/timeline.rs @@ -8,7 +8,7 @@ use log::{error}; use serde::Deserialize; use uuid::Uuid; use crate::api::errors::{ErrorCode, HttpError}; -use crate::api::utils::{check_user_in_room, parse_uuid}; +use crate::api::utils::{check_user_in_room}; use crate::core::AppState; use crate::keycloak::decode::KeycloakToken; use crate::model::{Message, MessageDTO, MsgType}; @@ -24,8 +24,8 @@ pub async fn scroll_chat_timeline( Path(room_id): Path, Query(params): Query ) -> impl IntoResponse { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &room_id).await { + + if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { return err.into_response(); } match state.message_repository.fetch_data(params.timestamp, room_id).await { diff --git a/src/api/utils.rs b/src/api/utils.rs index c0947fc..39ad9f7 100644 --- a/src/api/utils.rs +++ b/src/api/utils.rs @@ -9,10 +9,6 @@ use crate::api::errors::{ErrorCode, HttpError}; use crate::core::AppState; -pub fn parse_uuid(subject: &str) -> Result { - Uuid::try_parse(subject).map_err(|_| HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::ValidationError, "Can't parse token to UUID.")) -} - pub async fn check_user_in_room( state: &Arc, user_id: &Uuid, diff --git a/src/keycloak/decode.rs b/src/keycloak/decode.rs index 72f07dd..e454c25 100644 --- a/src/keycloak/decode.rs +++ b/src/keycloak/decode.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, OneOrMany}; use snafu::ResultExt; use tracing::debug; +use uuid::Uuid; use crate::keycloak::instance::KeycloakAuthInstance; use crate::keycloak::role::{ExpectRoles, KeycloakRole, NumRoles}; use super::{error::AuthError, role::ExtractRoles, role::Role}; @@ -267,7 +268,7 @@ where /// Audience (who or what the token is intended for). pub audience: Vec, /// Subject (whom the token refers to). This is the UUID which uniquely identifies this user inside Keycloak. - pub subject: String, + pub subject: Uuid, /// Authorized party (the party to which this token was issued). pub authorized_party: String, @@ -301,7 +302,13 @@ where jwt_id: raw.jti, issuer: raw.iss, audience: raw.aud, - subject: raw.sub, + subject: Uuid::try_parse(&raw.sub).map_err(|err| { + AuthError::InvalidToken { + reason: format!( + "Could not parse 'sub' (subject) field as uuid: {err}" + ), + } + })?, authorized_party: raw.azp, roles: { let mut roles = Vec::new(); diff --git a/src/repository/room_repository.rs b/src/repository/room_repository.rs index 4018c3a..d4751f6 100644 --- a/src/repository/room_repository.rs +++ b/src/repository/room_repository.rs @@ -17,7 +17,7 @@ impl RoomRepository { RoomRepository { pool } } - pub async fn start_transaction(&self) -> Result, Error> { + pub async fn start_transaction(&self) -> Result, Error> { let tx = self.pool.begin().await?; Ok(tx) } diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index e47f819..544c8fe 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,4 +1,4 @@ -use sqlx::{Pool, Postgres}; +use sqlx::{Error, Pool, Postgres, Transaction}; #[derive(Clone)] @@ -12,4 +12,9 @@ impl UserRepository { UserRepository { pool } } + pub async fn start_transaction(&self) -> Result, Error> { + let tx = self.pool.begin().await?; + Ok(tx) + } + } \ No newline at end of file diff --git a/src/repository/util.rs b/src/repository/util.rs index 9a69a9a..8b13789 100644 --- a/src/repository/util.rs +++ b/src/repository/util.rs @@ -1,6 +1 @@ -use sqlx::{Error, Pool, Postgres, Transaction}; -pub async fn start_transaction(pool: &Pool) -> Result, Error> { - let tx = pool.begin().await?; - Ok(tx) -} \ No newline at end of file From f6160e64733bd2ab7eca7044a88950c0a8beec38 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 12:11:13 +0100 Subject: [PATCH 04/23] folder restructure --- .idea/workspace.xml | 67 ++++++++++++------- Cargo.lock | 63 ++++++++++++++---- Cargo.toml | 6 +- {src/sql => sql}/import.sql | 0 src/api/mod.rs | 9 --- src/broadcast/notification.rs | 6 +- src/core/app_state.rs | 1 + src/{api => }/errors.rs | 0 src/lib.rs | 10 ++- src/main.rs | 2 +- src/{api => messaging}/messages.rs | 4 +- src/messaging/mod.rs | 3 + src/{api => messaging}/notifications.rs | 2 +- src/messaging/routes.rs | 14 ++++ src/model/mod.rs | 2 +- src/repository/user_repository.rs | 31 ++++++++- src/rooms/mod.rs | 3 + src/{api => rooms}/rooms.rs | 6 +- src/rooms/routes.rs | 21 ++++++ src/{api => rooms}/timeline.rs | 4 +- src/{api => }/router.rs | 31 +++------ src/user_relationship/mod.rs | 4 ++ src/user_relationship/model.rs | 85 +++++++++++++++++++++++++ src/user_relationship/routes.rs | 10 +++ src/user_relationship/user_handler.rs | 34 ++++++++++ src/user_relationship/utils.rs | 56 ++++++++++++++++ src/{api => }/utils.rs | 2 +- 27 files changed, 387 insertions(+), 89 deletions(-) rename {src/sql => sql}/import.sql (100%) delete mode 100644 src/api/mod.rs rename src/{api => }/errors.rs (100%) rename src/{api => messaging}/messages.rs (98%) create mode 100644 src/messaging/mod.rs rename src/{api => messaging}/notifications.rs (98%) create mode 100644 src/messaging/routes.rs create mode 100644 src/rooms/mod.rs rename src/{api => rooms}/rooms.rs (99%) create mode 100644 src/rooms/routes.rs rename src/{api => rooms}/timeline.rs (95%) rename src/{api => }/router.rs (61%) create mode 100644 src/user_relationship/mod.rs create mode 100644 src/user_relationship/model.rs create mode 100644 src/user_relationship/routes.rs create mode 100644 src/user_relationship/user_handler.rs create mode 100644 src/user_relationship/utils.rs rename src/{api => }/utils.rs (97%) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 09a0c35..33578b0 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -7,25 +7,40 @@ + - + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + - + @@ -390,14 +405,10 @@ - - - - diff --git a/Cargo.lock b/Cargo.lock index 56f411f..94e1825 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,10 +134,11 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "assertr" -version = "0.3.9" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8af1e6d90e4ec2f8252a47b1998af731ea49b4a5903732119baeb0b016472a4" +checksum = "e65c749b72cf7cbc5ea70eabf96ff497a25d791ac180ccee924adf3c4a32e22b" dependencies = [ + "futures", "indoc", "num", ] @@ -799,8 +800,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -817,13 +828,38 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.104", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.104", ] @@ -1872,7 +1908,7 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "ism" -version = "0.5.0" +version = "0.7.0" dependencies = [ "assertr", "atomic-time", @@ -3461,7 +3497,7 @@ version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162aed3aa5b6985d121d9e7e4137efebd49645dee204962b2e9ab85176349119" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.104", @@ -3606,9 +3642,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ "base64 0.22.1", "chrono", @@ -3617,8 +3653,7 @@ dependencies = [ "indexmap 2.7.0", "schemars 0.9.0", "schemars 1.0.4", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3626,11 +3661,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.104", diff --git a/Cargo.toml b/Cargo.toml index 04aac35..2d9d269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ism" -version = "0.5.0" +version = "0.7.0" edition = "2024" [dependencies] @@ -34,7 +34,7 @@ jsonwebtoken = { version = "10.1.0", features = ["aws_lc_rs"] } nonempty = { version = "0.12.0", features = ["std"] } reqwest = { version = "0.12.24", features = ["json"], default-features = false } serde-querystring = "0.3.0" -serde_with = "3.14.0" +serde_with = "3.15.1" snafu = "0.8.6" time = "0.3.41" try-again = "0.2.2" @@ -48,7 +48,7 @@ rustls-tls = ["reqwest/rustls-tls"] [dev-dependencies] -assertr = "0.3.9" +assertr = "0.4.2" tower-http = { version = "0.6.6", features = ["trace"] } tracing-subscriber = "0.3.20" uuid = { version = "1.18.1", features = ["v7", "serde"] } diff --git a/src/sql/import.sql b/sql/import.sql similarity index 100% rename from src/sql/import.sql rename to sql/import.sql diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index e958ac4..0000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod router; -mod errors; -mod rooms; -mod timeline; -mod utils; -mod messages; -mod notifications; - -pub use router::{init_router}; diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index cd720d5..ebdbb08 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -2,6 +2,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::model::{ChatRoom, MessageDTO}; +use crate::user_relationship::model::User; + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -16,10 +18,10 @@ pub struct Notification { pub enum NotificationEvent { #[serde(rename_all = "camelCase")] - FriendRequestReceived {from_user: serde_json::Value}, + FriendRequestReceived {from_user: User}, #[serde(rename_all = "camelCase")] - FriendRequestAccepted {from_user: serde_json::Value}, + FriendRequestAccepted {from_user: User}, /** * Different chat messages, sent to all active users in a room diff --git a/src/core/app_state.rs b/src/core/app_state.rs index 3a6fc1f..985fb5f 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -7,6 +7,7 @@ use crate::kafka::start_consumer; use crate::repository::room_repository::RoomRepository; use crate::repository::user_repository::UserRepository; + #[derive(Clone)] pub struct AppState { pub env: ISMConfig, diff --git a/src/api/errors.rs b/src/errors.rs similarity index 100% rename from src/api/errors.rs rename to src/errors.rs diff --git a/src/lib.rs b/src/lib.rs index 79e4444..1b6d9f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,14 @@ -pub mod api; pub mod core; pub mod database; pub mod model; pub mod broadcast; pub mod kafka; pub mod keycloak; -pub mod repository; \ No newline at end of file +pub mod repository; +pub mod user_relationship; +pub mod rooms; + +pub mod messaging; +pub mod utils; +pub mod errors; +pub mod router; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6113832..92b9933 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,9 @@ use tokio::{signal}; use tracing::info; use tracing_subscriber::EnvFilter; use ism::core::{AppState, ISMConfig}; -use ism::api::{init_router}; use tracing_subscriber::filter::LevelFilter; use ism::broadcast::BroadcastChannel; +use ism::router::init_router; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads diff --git a/src/api/messages.rs b/src/messaging/messages.rs similarity index 98% rename from src/api/messages.rs rename to src/messaging/messages.rs index b1bd2b3..6b05e20 100644 --- a/src/api/messages.rs +++ b/src/messaging/messages.rs @@ -7,8 +7,8 @@ use chrono::Utc; use http::{StatusCode}; use log::error; use uuid::Uuid; -use crate::api::errors::{ErrorCode, HttpError}; -use crate::api::timeline::msg_to_dto; +use crate::errors::{ErrorCode, HttpError}; +use crate::rooms::timeline::msg_to_dto; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::ChatMessage; use crate::core::AppState; diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs new file mode 100644 index 0000000..da7980a --- /dev/null +++ b/src/messaging/mod.rs @@ -0,0 +1,3 @@ +mod messages; +mod notifications; +pub mod routes; \ No newline at end of file diff --git a/src/api/notifications.rs b/src/messaging/notifications.rs similarity index 98% rename from src/api/notifications.rs rename to src/messaging/notifications.rs index f60b3f7..371ea52 100644 --- a/src/api/notifications.rs +++ b/src/messaging/notifications.rs @@ -9,7 +9,7 @@ use http::StatusCode; use log::error; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; -use crate::api::errors::{ErrorCode, HttpError}; +use crate::errors::{ErrorCode, HttpError}; use crate::broadcast::{BroadcastChannel, SendNotification}; use crate::core::AppState; use crate::keycloak::decode::KeycloakToken; diff --git a/src/messaging/routes.rs b/src/messaging/routes.rs new file mode 100644 index 0000000..8d1b96b --- /dev/null +++ b/src/messaging/routes.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; +use axum::Router; +use axum::routing::{get, post}; +use crate::core::AppState; +use crate::messaging::messages::send_message; +use crate::messaging::notifications::{add_notification, poll_for_new_notifications, stream_server_events}; + +pub fn create_messaging_routes() -> Router> { + Router::new() //add new routes here + .route("/api/notify", get(poll_for_new_notifications)) + .route("/api/sse", get(stream_server_events)) + .route("/api/notify", post(add_notification)) + .route("/api/send-msg", post(send_message)) +} \ No newline at end of file diff --git a/src/model/mod.rs b/src/model/mod.rs index 6dc3468..6f3de0e 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -9,4 +9,4 @@ pub use room_member::*; pub use room::*; pub use message::*; pub use response_utils::*; -pub use queries::*; \ No newline at end of file +pub use queries::*; diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 544c8fe..540f027 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,5 +1,6 @@ -use sqlx::{Error, Pool, Postgres, Transaction}; - +use sqlx::{query_as, Error, Pool, Postgres, Transaction}; +use uuid::Uuid; +use crate::user_relationship::model::{UserWithRelationship}; #[derive(Clone)] pub struct UserRepository { @@ -17,4 +18,30 @@ impl UserRepository { Ok(tx) } + pub async fn find_user_by_id_with_relationship_type(&self, client_id: &Uuid, searched_user_id: &Uuid) -> Result, Error> { + let user = query_as::<_, UserWithRelationship>( + r#"SELECT + r_user.id, + r_user.display_name, + r_user.profile_picture, + r_user.street_credits, + r_user.description, + r_user.friends_count, + user_relationship.user_a_id, + user_relationship.user_b_id, + user_relationship.state, + user_relationship.relationship_change_timestamp + FROM app_user r_user + LEFT JOIN user_relationship ON + (user_relationship.user_a_id = r_user.id AND user_relationship.user_b_id = $2) OR + (user_relationship.user_b_id = r_user.id AND user_relationship.user_a_id = $2) + WHERE r_user.id = $1 AND r_user.id <> $2 + "# + ) + .bind(searched_user_id) + .bind(client_id) + .fetch_optional(&self.pool).await?; + Ok(user) + } + } \ No newline at end of file diff --git a/src/rooms/mod.rs b/src/rooms/mod.rs new file mode 100644 index 0000000..e13ff31 --- /dev/null +++ b/src/rooms/mod.rs @@ -0,0 +1,3 @@ +pub mod routes; +mod rooms; +pub mod timeline; \ No newline at end of file diff --git a/src/api/rooms.rs b/src/rooms/rooms.rs similarity index 99% rename from src/api/rooms.rs rename to src/rooms/rooms.rs index 52dde85..28b4850 100644 --- a/src/api/rooms.rs +++ b/src/rooms/rooms.rs @@ -7,11 +7,11 @@ use chrono::{Utc}; use log::{error, info}; use uuid::Uuid; use bytes::Bytes; -use crate::api::errors::{ErrorCode, HttpError}; -use crate::api::timeline::{msg_to_dto}; +use crate::errors::{ErrorCode, HttpError}; +use crate::rooms::timeline::{msg_to_dto}; use crate::keycloak::decode::KeycloakToken; use crate::model::{ChatRoomWithUserDTO, MembershipStatus, Message, MessageBody, NewRoom as UploadRoom, RoomType, RoomChangeBody, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; -use crate::api::utils::{check_user_in_room, crop_image_from_center}; +use crate::utils::{check_user_in_room, crop_image_from_center}; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; use crate::core::AppState; diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs new file mode 100644 index 0000000..84f0da2 --- /dev/null +++ b/src/rooms/routes.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; +use axum::Router; +use axum::routing::{get, post}; +use crate::core::AppState; +use crate::rooms::rooms::{create_room, get_joined_rooms, get_room_list_item_by_id, get_room_with_details, get_users_in_room, invite_to_room, leave_room, mark_room_as_read, save_room_image, search_existing_single_room}; +use crate::rooms::timeline::scroll_chat_timeline; + +pub fn create_room_routes() -> Router> { + Router::new() + .route("/api/rooms/create-room", post(create_room)) + .route("/api/rooms/{room_id}/users", get(get_users_in_room)) + .route("/api/rooms/{room_id}/detailed", get(get_room_with_details)) + .route("/api/rooms/{room_id}/timeline", get(scroll_chat_timeline)) + .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) + .route("/api/rooms/{room_id}", get(get_room_list_item_by_id)) + .route("/api/rooms/{room_id}/leave", post(leave_room)) + .route("/api/rooms/search", get(search_existing_single_room)) + .route("/api/rooms/{room_id}/invite/{user_id}", post(invite_to_room)) + .route("/api/rooms/{room_id}/upload-img", post(save_room_image)) + .route("/api/rooms", get(get_joined_rooms)) +} diff --git a/src/api/timeline.rs b/src/rooms/timeline.rs similarity index 95% rename from src/api/timeline.rs rename to src/rooms/timeline.rs index a72f8be..fce521e 100644 --- a/src/api/timeline.rs +++ b/src/rooms/timeline.rs @@ -7,8 +7,8 @@ use chrono::{DateTime, Utc}; use log::{error}; use serde::Deserialize; use uuid::Uuid; -use crate::api::errors::{ErrorCode, HttpError}; -use crate::api::utils::{check_user_in_room}; +use crate::errors::{ErrorCode, HttpError}; +use crate::utils::{check_user_in_room}; use crate::core::AppState; use crate::keycloak::decode::KeycloakToken; use crate::model::{Message, MessageDTO, MsgType}; diff --git a/src/api/router.rs b/src/router.rs similarity index 61% rename from src/api/router.rs rename to src/router.rs index a91a1bc..2c0ffbf 100644 --- a/src/api/router.rs +++ b/src/router.rs @@ -4,21 +4,19 @@ use axum::{Router}; use axum::extract::DefaultBodyLimit; use axum::http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use axum::response::IntoResponse; -use axum::routing::{get, post}; +use axum::routing::{get}; use http::header::{CONNECTION, CONTENT_LENGTH, ORIGIN}; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use tower::ServiceBuilder; use url::Url; -use crate::api::messages::send_message; -use crate::api::notifications::{add_notification, poll_for_new_notifications, stream_server_events}; -use crate::api::rooms::{create_room, get_joined_rooms, get_room_list_item_by_id, get_room_with_details, get_users_in_room, invite_to_room, leave_room, mark_room_as_read, save_room_image, search_existing_single_room}; -use crate::api::timeline::scroll_chat_timeline; use crate::core::{AppState, TokenIssuer}; use crate::keycloak::instance::{KeycloakAuthInstance, KeycloakConfig}; use crate::keycloak::layer::KeycloakAuthLayer; use crate::keycloak::PassthroughMode; - +use crate::messaging::routes::create_messaging_routes; +use crate::rooms::routes::create_room_routes; +use crate::user_relationship::routes::create_user_routes; /** * Initializing the api routes. @@ -35,23 +33,12 @@ pub async fn init_router(app_state: AppState) -> Router { .route("/", get(|| async { "Hello, world! I'm your new ISM. 🤗" })) .route("/health", get(|| async { (StatusCode::OK, "Healthy").into_response() })); + let protected_routing = Router::new() //add new routes here - .route("/api/notify", get(poll_for_new_notifications)) - .route("/api/sse", get(stream_server_events)) - .route("/api/notify", post(add_notification)) - .route("/api/send-msg", post(send_message)) - .route("/api/rooms/create-room", post(create_room)) - .route("/api/rooms/{room_id}/users", get(get_users_in_room)) - .route("/api/rooms/{room_id}/detailed", get(get_room_with_details)) - .route("/api/rooms/{room_id}/timeline", get(scroll_chat_timeline)) - .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) - .route("/api/rooms/{room_id}", get(get_room_list_item_by_id)) - .route("/api/rooms/{room_id}/leave", post(leave_room)) - .route("/api/rooms/search", get(search_existing_single_room)) - .route("/api/rooms/{room_id}/invite/{user_id}", post(invite_to_room)) - .route("/api/rooms/{room_id}/upload-img", post(save_room_image)) - .route("/api/rooms", get(get_joined_rooms)) - + .merge(create_room_routes()) + .merge(create_user_routes()) + .merge(create_messaging_routes()) + //layering bottom to top middleware .layer( ServiceBuilder::new() //layering top to bottom middleware diff --git a/src/user_relationship/mod.rs b/src/user_relationship/mod.rs new file mode 100644 index 0000000..c5d39c9 --- /dev/null +++ b/src/user_relationship/mod.rs @@ -0,0 +1,4 @@ +pub mod model; +mod utils; +mod user_handler; +pub mod routes; \ No newline at end of file diff --git a/src/user_relationship/model.rs b/src/user_relationship/model.rs new file mode 100644 index 0000000..5a04b9e --- /dev/null +++ b/src/user_relationship/model.rs @@ -0,0 +1,85 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Type}; +use uuid::Uuid; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FriendRequestResult { + pub id: Uuid, + pub from_user: User, +} + +#[derive(Debug, Deserialize, Serialize, Clone, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct UserRelationship { + pub user_a_id: Uuid, + pub user_b_id: Uuid, + pub state: RelationshipState, + pub relationship_change_timestamp: DateTime +} + +#[derive(Debug, FromRow)] +pub struct UserWithRelationship { + #[sqlx(flatten)] + pub r_user: User, + + user_a_id: Option, + user_b_id: Option, + #[sqlx(rename = "state")] + relationship_state: Option, + relationship_change_timestamp: Option>, +} + +impl UserWithRelationship { + pub fn get_relationship(&self) -> Option { + if self.user_a_id.is_some() && self.user_b_id.is_some() && self.relationship_state.is_some() && self.relationship_change_timestamp.is_some() { + Some(UserRelationship { + user_a_id: self.user_a_id.unwrap(), + user_b_id: self.user_b_id.unwrap(), + state: self.relationship_state.clone().unwrap(), + relationship_change_timestamp: self.relationship_change_timestamp.unwrap(), + }) + } else { + None + } + } +} + +#[derive(Serialize)] +pub struct UserWithRelationshipDto { + pub user: User, + pub relationship_type: Option, +} + +#[allow(non_camel_case_types)] +#[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] +#[sqlx(type_name = "state")] +pub enum RelationshipState { + A_BLOCKED, + B_BLOCKED, + ALL_BLOCKED, + FRIEND, + A_INVITED, + B_INVITED +} + +#[derive(Serialize)] +pub enum Relationship { + InviteReceived, + InviteSent, + ClientBlocked, + ClientGotBlocked, + Friend +} + +#[derive(Debug, Serialize, Deserialize, Clone, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: Uuid, + pub display_name: String, + pub street_credits: i64, + pub profile_picture: Option, + pub description: Option, + pub friends_count: i64 +} \ No newline at end of file diff --git a/src/user_relationship/routes.rs b/src/user_relationship/routes.rs new file mode 100644 index 0000000..106a3de --- /dev/null +++ b/src/user_relationship/routes.rs @@ -0,0 +1,10 @@ +use std::sync::Arc; +use axum::Router; +use axum::routing::get; +use crate::core::AppState; +use crate::user_relationship::user_handler::search_user_by_id; + +pub fn create_user_routes() -> Router> { + Router::new() + .route("/api/users/{user_id}", get(search_user_by_id)) +} \ No newline at end of file diff --git a/src/user_relationship/user_handler.rs b/src/user_relationship/user_handler.rs new file mode 100644 index 0000000..3f5bbe3 --- /dev/null +++ b/src/user_relationship/user_handler.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use axum::response::IntoResponse; +use http::StatusCode; +use uuid::Uuid; +use crate::core::AppState; +use crate::errors::ErrorCode::UnexpectedError; +use crate::errors::{ErrorCode, HttpError}; +use crate::keycloak::decode::KeycloakToken; +use crate::user_relationship::model::UserWithRelationshipDto; +use crate::user_relationship::utils::resolve_relationship_state; + +pub async fn search_user_by_id( + State(state): State>, + Path(user_id): Path, + Extension(token): Extension>, +) -> impl IntoResponse { + match state.user_repository.find_user_by_id_with_relationship_type(&token.subject, &user_id).await { + Ok(user) => { + match user { + None => HttpError::new(StatusCode::NOT_FOUND, ErrorCode::RoomNotFound, "Room not found").into_response(), + Some(user) => { + let response = UserWithRelationshipDto { + user: user.r_user.clone(), + relationship_type: resolve_relationship_state(user.r_user.id, user.get_relationship()) + }; + Json(response).into_response() + } + } + }, + Err(err) => HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, UnexpectedError, err.to_string()).into_response() + } +} diff --git a/src/user_relationship/utils.rs b/src/user_relationship/utils.rs new file mode 100644 index 0000000..452132c --- /dev/null +++ b/src/user_relationship/utils.rs @@ -0,0 +1,56 @@ +use uuid::Uuid; +use crate::user_relationship::model::{Relationship, RelationshipState, UserRelationship}; + +pub fn resolve_relationship_state( + client_id: Uuid, + relationship: Option, +) -> Option { + + let relationship = relationship?; + + + match relationship.state { + + RelationshipState::FRIEND => Some(Relationship::Friend), + + RelationshipState::A_BLOCKED => { + if relationship.user_a_id == client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::B_BLOCKED => { + if relationship.user_b_id == client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::ALL_BLOCKED => { + if relationship.user_b_id == client_id || relationship.user_a_id == client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::A_INVITED => { + if relationship.user_a_id == client_id { + Some(Relationship::InviteSent) + } else { + Some(Relationship::InviteReceived) + } + } + + RelationshipState::B_INVITED => { + if relationship.user_b_id == client_id { + Some(Relationship::InviteSent) + } else { + Some(Relationship::InviteReceived) + } + } + } +} \ No newline at end of file diff --git a/src/api/utils.rs b/src/utils.rs similarity index 97% rename from src/api/utils.rs rename to src/utils.rs index 39ad9f7..3b26344 100644 --- a/src/api/utils.rs +++ b/src/utils.rs @@ -5,7 +5,7 @@ use std::io::Cursor; use http::StatusCode; use image::GenericImageView; use log::error; -use crate::api::errors::{ErrorCode, HttpError}; +use crate::errors::{ErrorCode, HttpError}; use crate::core::AppState; From 560b49deed9daec234a8b33e0f8cd27b3df2d449 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 18:35:03 +0100 Subject: [PATCH 05/23] implement user relationships --- .idea/workspace.xml | 66 +++-- Cargo.lock | 1 + Cargo.toml | 1 + src/core/cursor.rs | 70 +++++ src/core/mod.rs | 3 +- src/errors.rs | 101 ++++++- src/main.rs | 1 + src/repository/user_repository.rs | 211 ++++++++++++++- src/router.rs | 2 +- src/user_relationship/mod.rs | 4 +- src/user_relationship/model.rs | 98 ++++++- src/user_relationship/query_param.rs | 7 + src/user_relationship/routes.rs | 18 +- src/user_relationship/user_handler.rs | 138 ++++++++-- src/user_relationship/user_service.rs | 368 ++++++++++++++++++++++++++ src/user_relationship/utils.rs | 13 +- src/utils.rs | 2 + 17 files changed, 1021 insertions(+), 83 deletions(-) create mode 100644 src/core/cursor.rs create mode 100644 src/user_relationship/query_param.rs create mode 100644 src/user_relationship/user_service.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 33578b0..8349745 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,35 +12,24 @@ - - - - - - - - - - - + + + + - - - - - - - - - - - + + - - + + + + + + + @@ -823,7 +817,6 @@ - @@ -848,7 +841,8 @@ - diff --git a/Cargo.lock b/Cargo.lock index 94e1825..db22d11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1913,6 +1913,7 @@ dependencies = [ "assertr", "atomic-time", "axum", + "base64 0.22.1", "bytes", "chrono", "config", diff --git a/Cargo.toml b/Cargo.toml index 2d9d269..c273521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ samsa = "0.1.7" minio = { version = "0.3.0", features = ["default"] } image = { version = "0.25.8"} bytes = "1.10.1" +base64 = "0.22.1" #keycloak: atomic-time = "0.1.5" diff --git a/src/core/cursor.rs b/src/core/cursor.rs new file mode 100644 index 0000000..898fded --- /dev/null +++ b/src/core/cursor.rs @@ -0,0 +1,70 @@ +use std::fmt; +use base64::Engine; +use base64::engine::general_purpose; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub trait Cursor: Serialize + DeserializeOwned + Default {} +impl Cursor for T where T: Serialize + DeserializeOwned + Default {} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorResults { + pub next_cursor: Option, + pub content: Vec, +} + +pub fn decode_cursor(base64_cursor: Option) -> Result { + match base64_cursor { + Some(encoded_cursor) => { + let decoded_bytes = general_purpose::URL_SAFE_NO_PAD.decode(encoded_cursor.as_bytes())?; + let cursor: T = serde_json::from_slice(&decoded_bytes)?; + Ok(cursor) + }, + None => { + Ok(T::default()) + } + } +} + +pub fn encode_cursor(cursor: &T) -> Result { + let json_bytes = serde_json::to_vec(cursor)?; + let encoded_cursor = general_purpose::URL_SAFE_NO_PAD.encode(&json_bytes); + Ok(encoded_cursor) +} + +#[derive(Debug)] +pub enum CursorError { + Base64Decode(base64::DecodeError), + Json(serde_json::Error), +} + +impl fmt::Display for CursorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CursorError::Base64Decode(_) => write!(f, "Ungültiger Base64-Cursor"), + CursorError::Json(_) => write!(f, "Cursor-Daten konnten nicht als JSON verarbeitet werden"), + } + } +} + +impl std::error::Error for CursorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CursorError::Base64Decode(e) => Some(e), + CursorError::Json(e) => Some(e), + } + } +} + +impl From for CursorError { + fn from(err: base64::DecodeError) -> Self { + CursorError::Base64Decode(err) + } +} + +impl From for CursorError { + fn from(err: serde_json::Error) -> Self { + CursorError::Json(err) + } +} \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs index 0539de3..10bb3f1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,5 +1,6 @@ mod config; mod app_state; +pub mod cursor; pub use config::{ISMConfig, UserDbConfig, MessageDbConfig, ObjectStorageConfig, TokenIssuer, KafkaConfig}; -pub use app_state::*; \ No newline at end of file +pub use app_state::*; diff --git a/src/errors.rs b/src/errors.rs index 46fe3ad..852d4c8 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,4 +1,6 @@ -use std::fmt::Display; +use std::error::Error; +use std::fmt; +use std::fmt::{Display, Formatter}; use axum::http::StatusCode; use axum::Json; use axum::response::{IntoResponse, Response}; @@ -32,6 +34,8 @@ pub enum ErrorCode { InvalidContent, FileProcessingError, + ContentNotFound, + // General API & Validation Errors ValidationError, ServiceUnavailable, @@ -102,4 +106,99 @@ impl IntoResponse for HttpError { (status, Json(error_response)).into_response() } +} + +pub enum AppError { + /// Ein Fehler, der von einer ungültigen Anfrage des Clients herrührt. + ValidationError(String), + + /// Ein angeforderter Datensatz wurde nicht gefunden. + NotFound(String), + + /// Ein Fehler, der aus der Datenbank kommt. Wir verpacken den ursprünglichen Fehler. + /// `Box` ist der Standardweg in Rust, um einen beliebigen Fehler zu speichern. + DatabaseError(Box), + + /// Ein interner Fehler bei der Verarbeitung, z.B. beim Kodieren/Dekodieren. + ProcessingError(String), + + Blocked(String), +} + +impl fmt::Debug for AppError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::ValidationError(msg) => write!(f, "ValidationError: {}", msg), + Self::NotFound(msg) => write!(f, "NotFound: {}", msg), + Self::DatabaseError(err) => write!(f, "DatabaseError: {}", err), + Self::ProcessingError(msg) => write!(f, "ProcessingError: {}", msg), + Self::Blocked(msg) => write!(f, "Blocked: {}", msg), + } + } +} + +impl Display for AppError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + AppError::ValidationError(msg) => write!(f, "Invalid input: {}", msg), + AppError::NotFound(msg) => write!(f, "Entity not found: {}", msg), + AppError::DatabaseError(err) => write!(f, "Ein Datenbankfehler ist aufgetreten: {}", err), + AppError::ProcessingError(msg) => write!(f, "Ein Verarbeitungsfehler ist aufgetreten: {}", msg), + AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), + } + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> AppError { + AppError::DatabaseError(Box::new(err)) + } +} + +impl Error for AppError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + AppError::DatabaseError(err) => Some(err.as_ref()), + _ => None, + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + + let http_error = match self { + AppError::ValidationError(msg) => { + HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::ValidationError, msg) + } + AppError::NotFound(msg) => { + HttpError::new(StatusCode::NOT_FOUND, ErrorCode::ContentNotFound, msg) + } + AppError::DatabaseError(internal_err) => { + tracing::error!("Database error: {:?}", internal_err); + HttpError::new( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::ServiceUnavailable, + "Internal service outage." + ) + } + AppError::ProcessingError(msg) => { + tracing::error!("Intern processing error: {}", msg); + HttpError::new( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::UnexpectedError, + "Unexpected server error processing." + ) + } + AppError::Blocked(msg) => { + HttpError::new( + StatusCode::FORBIDDEN, + ErrorCode::InsufficientPermissions, + msg + ) + } + }; + + http_error.into_response() + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 92b9933..a5c7c32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use ism::router::init_router; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads //https://github.com/AarambhDevHub/rust-backend-axum +//https://github.com/rust-lang/crates.io/ #[tokio::main(flavor = "multi_thread")] async fn main() { let config = init_configuration(); diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 540f027..caa2e0e 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,6 +1,6 @@ -use sqlx::{query_as, Error, Pool, Postgres, Transaction}; +use sqlx::{query_as, Error, PgConnection, Pool, Postgres, Transaction}; use uuid::Uuid; -use crate::user_relationship::model::{UserWithRelationship}; +use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationship, UserWithRelationship}; #[derive(Clone)] pub struct UserRepository { @@ -44,4 +44,211 @@ impl UserRepository { Ok(user) } + pub async fn find_user_by_id(&self, user_id: &Uuid) -> Result, Error> { + let user = query_as!( + User, + r#"SELECT + r_user.id, + r_user.display_name, + r_user.profile_picture, + r_user.street_credits, + r_user.description, + r_user.friends_count + FROM app_user r_user + WHERE r_user.id = $1 + "#, user_id + ).fetch_optional(&self.pool).await?; + Ok(user) + } + + pub async fn find_user_by_name_with_relationship_type(&self, client_id: &Uuid, username: &str, page_size: i64, cursor: UserPaginationCursor) -> Result, Error> { + let user = query_as::<_, UserWithRelationship>( + r#"SELECT + r_user.id, + r_user.display_name, + r_user.profile_picture, + r_user.street_credits, + r_user.description, + r_user.friends_count, + user_relationship.user_a_id, + user_relationship.user_b_id, + user_relationship.state, + user_relationship.relationship_change_timestamp + FROM app_user r_user + LEFT JOIN user_relationship ON + (user_relationship.user_a_id = r_user.id AND user_relationship.user_b_id = $2) OR + (user_relationship.user_b_id = r_user.id AND user_relationship.user_a_id = $2) + WHERE + r_user.raw_name LIKE lower(concat('%', $1, '%')) + AND r_user.id <> $2 + AND ($3 IS NULL OR (r_user.display_name, r_user.id) > ($3, $4)) + ORDER BY r_user.display_name ASC, r_user.id ASC + LIMIT $5 + "# + ) + .bind(username) + .bind(client_id) + .bind(cursor.last_seen_name) + .bind(cursor.last_seen_id) + .bind(page_size) + .fetch_all(&self.pool).await?; + Ok(user) + } + + pub async fn select_open_friend_requests(&self, client_id: &Uuid) -> Result, Error> { + let requests = sqlx::query_as!( + User, + r#"SELECT + u.id, + u.display_name, + u.profile_picture, + u.street_credits, + u.description, + u.friends_count + FROM app_user u + INNER JOIN user_relationship ur ON + (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR + (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED') + "#, + client_id + ).fetch_all(&self.pool).await?; + Ok(requests) + } + + pub async fn find_users_with_specific_relationship( + &self, + client_id: &Uuid, + state: RelationshipState, + ) -> Result, Error> { + let users = sqlx::query_as!( + User, + r#" + SELECT + u.id, + u.display_name, + u.profile_picture, + u.street_credits, + u.description, + u.friends_count + FROM + app_user u + INNER JOIN + user_relationship rl ON u.id = ( + CASE + WHEN rl.user_a_id = $1 THEN rl.user_b_id + WHEN rl.user_b_id = $1 THEN rl.user_a_id + ELSE NULL + END + ) + WHERE + rl.state = $2 + "#, + client_id, + state.to_string() + ).fetch_all(&self.pool).await?; + Ok(users) + } + + pub async fn search_for_relationship(&self, conn: &mut PgConnection, client_id: &Uuid, other_id: &Uuid) -> Result, Error> + { + let relationship = sqlx::query_as!( + UserRelationship, + r#" + SELECT + ur.user_a_id, + ur.user_b_id, + ur.state as "state: RelationshipState", + ur.relationship_change_timestamp + FROM user_relationship ur + WHERE ur.user_a_id = $1 AND ur.user_b_id = $2 OR ur.user_b_id = $1 AND ur.user_a_id = $2 + FOR UPDATE + "#, + client_id, + other_id + ).fetch_optional(&mut *conn).await?; + Ok(relationship) + } + + pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: UserRelationship) -> Result<(), Error> { + sqlx::query!( + r#" + INSERT INTO user_relationship (user_a_id, user_b_id, state, relationship_change_timestamp) + VALUES ($1, $2, $3, $4) + "#, + user_relationship.user_a_id, + user_relationship.user_b_id, + user_relationship.state.to_string(), + user_relationship.relationship_change_timestamp + ).execute(&mut *conn).await?; + Ok(()) + } + + pub async fn update_relationship_state( + &self, + conn: &mut PgConnection, + user_a_id: &Uuid, + user_b_id: &Uuid, + new_state: RelationshipState, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE user_relationship + SET state = $1, relationship_change_timestamp = NOW() + WHERE user_a_id = $2 AND user_b_id = $3 + "#, + new_state.to_string(), + user_a_id, + user_b_id + ).execute(&mut *conn).await?; + Ok(()) + } + + pub async fn delete_relationship_state( + &self, + conn: &mut PgConnection, + user_relationship: UserRelationship + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM user_relationship + WHERE user_a_id = $1 AND user_b_id = $2 + "#, + user_relationship.user_a_id, + user_relationship.user_b_id + ).execute(&mut *conn).await?; + Ok(()) + } + + pub async fn increment_friends_count( + &self, + tx: &mut PgConnection, + user_id: &Uuid, + ) -> Result<(), Error> { + sqlx::query!( + r#" + UPDATE app_user + SET friends_count = friends_count + 1 + WHERE id = $1 + "#, + user_id + ).execute(tx).await?; + Ok(()) + } + + pub async fn decrement_friends_count( + &self, + tx: &mut PgConnection, + user_id: &Uuid, + ) -> Result<(), Error> { + sqlx::query!( + r#" + UPDATE app_user + SET friends_count = friends_count - 1 + WHERE id = $1 + "#, + user_id + ).execute(tx).await?; + Ok(()) + } + } \ No newline at end of file diff --git a/src/router.rs b/src/router.rs index 2c0ffbf..e6c9c50 100644 --- a/src/router.rs +++ b/src/router.rs @@ -27,7 +27,7 @@ pub async fn init_router(app_state: AppState) -> Router { .allow_origin(origin.parse::().unwrap()) .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE, CONTENT_LENGTH, CONNECTION, ORIGIN]) .allow_credentials(true) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]); + .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE]); let public_routing = Router::new() .route("/", get(|| async { "Hello, world! I'm your new ISM. 🤗" })) diff --git a/src/user_relationship/mod.rs b/src/user_relationship/mod.rs index c5d39c9..2ec2e08 100644 --- a/src/user_relationship/mod.rs +++ b/src/user_relationship/mod.rs @@ -1,4 +1,6 @@ pub mod model; mod utils; mod user_handler; -pub mod routes; \ No newline at end of file +pub mod routes; +mod query_param; +mod user_service; \ No newline at end of file diff --git a/src/user_relationship/model.rs b/src/user_relationship/model.rs index 5a04b9e..760e121 100644 --- a/src/user_relationship/model.rs +++ b/src/user_relationship/model.rs @@ -1,6 +1,9 @@ +use std::error::Error; +use std::fmt; +use std::fmt::{Display, Formatter}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Type}; +use sqlx::{FromRow, Row, Type}; use uuid::Uuid; #[derive(Serialize, Clone)] @@ -10,8 +13,7 @@ pub struct FriendRequestResult { pub from_user: User, } -#[derive(Debug, Deserialize, Serialize, Clone, FromRow)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone)] pub struct UserRelationship { pub user_a_id: Uuid, pub user_b_id: Uuid, @@ -19,14 +21,11 @@ pub struct UserRelationship { pub relationship_change_timestamp: DateTime } -#[derive(Debug, FromRow)] +#[derive(Debug)] pub struct UserWithRelationship { - #[sqlx(flatten)] pub r_user: User, - user_a_id: Option, user_b_id: Option, - #[sqlx(rename = "state")] relationship_state: Option, relationship_change_timestamp: Option>, } @@ -46,15 +45,51 @@ impl UserWithRelationship { } } +impl<'r, R: Row> FromRow<'r, R> for UserWithRelationship +where + &'r str: sqlx::ColumnIndex, + Uuid: sqlx::Decode<'r, R::Database> + sqlx::Type, + String: sqlx::Decode<'r, R::Database> + sqlx::Type, + i64: sqlx::Decode<'r, R::Database> + sqlx::Type, + DateTime: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + + fn from_row(row: &'r R) -> Result { + + let r_user = User::from_row(row)?; + let state_str: Option = row.try_get("state")?; + + let relationship_state: Option = state_str + .map(RelationshipState::try_from) + .transpose() + .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; + + let user_a_id = row.try_get("user_a_id")?; + let user_b_id = row.try_get("user_b_id")?; + let relationship_change_timestamp = row.try_get("relationship_change_timestamp")?; + + Ok(UserWithRelationship { + r_user, + user_a_id, + user_b_id, + relationship_state, + relationship_change_timestamp, + }) + } +} + #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct UserWithRelationshipDto { pub user: User, pub relationship_type: Option, } + + #[allow(non_camel_case_types)] #[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] -#[sqlx(type_name = "state")] +#[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RelationshipState { A_BLOCKED, B_BLOCKED, @@ -64,7 +99,47 @@ pub enum RelationshipState { B_INVITED } +#[derive(Debug)] +pub struct InvalidState(String); + +impl fmt::Display for InvalidState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unknown RelationshipState-Value: '{}'", self.0) + } +} +impl Error for InvalidState {} + +impl TryFrom for RelationshipState { + + type Error = InvalidState; + fn try_from(value: String) -> Result { + match value.as_str() { + "A_BLOCKED" => Ok(Self::A_BLOCKED), + "B_BLOCKED" => Ok(Self::B_BLOCKED), + "ALL_BLOCKED" => Ok(Self::ALL_BLOCKED), + "FRIEND" => Ok(Self::FRIEND), + "A_INVITED" => Ok(Self::A_INVITED), + "B_INVITED" => Ok(Self::B_INVITED), + _ => Err(InvalidState(value)), + } + } +} + +impl Display for RelationshipState { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + RelationshipState::FRIEND => write!(f, "FRIEND"), + RelationshipState::B_BLOCKED => write!(f, "B_BLOCKED"), + RelationshipState::A_BLOCKED => write!(f, "A_BLOCKED"), + RelationshipState::ALL_BLOCKED => write!(f, "ALL_BLOCKED"), + RelationshipState::A_INVITED => write!(f, "A_INVITED"), + RelationshipState::B_INVITED => write!(f, "B_INVITED"), + } + } +} + #[derive(Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Relationship { InviteReceived, InviteSent, @@ -82,4 +157,11 @@ pub struct User { pub profile_picture: Option, pub description: Option, pub friends_count: i64 +} + +#[derive(Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UserPaginationCursor { + pub last_seen_name: Option, + pub last_seen_id: Option, } \ No newline at end of file diff --git a/src/user_relationship/query_param.rs b/src/user_relationship/query_param.rs new file mode 100644 index 0000000..c4a9ea2 --- /dev/null +++ b/src/user_relationship/query_param.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct UserSearchParams { + pub username: String, + pub cursor: Option, +} \ No newline at end of file diff --git a/src/user_relationship/routes.rs b/src/user_relationship/routes.rs index 106a3de..0b1e203 100644 --- a/src/user_relationship/routes.rs +++ b/src/user_relationship/routes.rs @@ -1,10 +1,22 @@ use std::sync::Arc; use axum::Router; -use axum::routing::get; +use axum::routing::{delete, get, post}; use crate::core::AppState; -use crate::user_relationship::user_handler::search_user_by_id; +use crate::user_relationship::user_handler::{handle_accept_friend_request, handle_add_friend, handle_get_friends, handle_get_open_friend_requests, handle_ignore_user, handle_reject_friend_request, handle_remove_friend, handle_search_user_by_id, handle_search_user_by_name, handle_undo_ignore_user}; pub fn create_user_routes() -> Router> { + Router::new() - .route("/api/users/{user_id}", get(search_user_by_id)) + .route("/api/users/{user_id}", get(handle_search_user_by_id)) + .route("/api/users/search", get(handle_search_user_by_name)) + .route("/api/users/friends/requests", get(handle_get_open_friend_requests)) + .route("/api/users/friends", get(handle_get_friends)) + .route("/api/users/friends/add/{user_id}", post(handle_add_friend)) + .route("/api/users/friends/accept-request/{sender_id}", post(handle_accept_friend_request)) + .route("/api/users/friends/reject-request/{sender_id}", delete(handle_reject_friend_request)) + .route("/api/users/friends/{friend_id}", delete(handle_remove_friend)) + .route("/api/users/ignore/{user_id}", post(handle_ignore_user)) + .route("/api/users/ignore/{user_id}", delete(handle_undo_ignore_user)) + + } \ No newline at end of file diff --git a/src/user_relationship/user_handler.rs b/src/user_relationship/user_handler.rs index 3f5bbe3..cdbff53 100644 --- a/src/user_relationship/user_handler.rs +++ b/src/user_relationship/user_handler.rs @@ -1,34 +1,124 @@ use std::sync::Arc; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::{Extension, Json}; -use axum::response::IntoResponse; -use http::StatusCode; use uuid::Uuid; use crate::core::AppState; -use crate::errors::ErrorCode::UnexpectedError; -use crate::errors::{ErrorCode, HttpError}; +use crate::core::cursor::{decode_cursor, CursorResults}; +use crate::errors::{AppError}; use crate::keycloak::decode::KeycloakToken; -use crate::user_relationship::model::UserWithRelationshipDto; -use crate::user_relationship::utils::resolve_relationship_state; +use crate::user_relationship::model::{User, UserPaginationCursor, UserWithRelationshipDto}; +use crate::user_relationship::query_param::UserSearchParams; +use crate::user_relationship::user_service::UserService; -pub async fn search_user_by_id( + +pub async fn handle_search_user_by_id( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -) -> impl IntoResponse { - match state.user_repository.find_user_by_id_with_relationship_type(&token.subject, &user_id).await { - Ok(user) => { - match user { - None => HttpError::new(StatusCode::NOT_FOUND, ErrorCode::RoomNotFound, "Room not found").into_response(), - Some(user) => { - let response = UserWithRelationshipDto { - user: user.r_user.clone(), - relationship_type: resolve_relationship_state(user.r_user.id, user.get_relationship()) - }; - Json(response).into_response() - } - } - }, - Err(err) => HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, UnexpectedError, err.to_string()).into_response() - } +) -> Result, AppError> { + + let user_dto = UserService::query_user_by_id( + state, + &token.subject, + &user_id + ).await?; + + Ok(Json(user_dto)) } + +pub async fn handle_search_user_by_name( + State(state): State>, + Extension(token): Extension>, + Query(params): Query +) -> Result>, AppError> { + + let cursor: UserPaginationCursor = decode_cursor(params.cursor) + .map_err(|_| AppError::ValidationError("Invalid Cursor-Parameters.".to_string()))?; + + let search_results = UserService::query_user_by_name( + state, + &token.subject, + ¶ms.username, + cursor + ).await?; + + Ok(Json(search_results)) +} + +pub async fn handle_get_open_friend_requests( + State(state): State>, + Extension(token): Extension>, +) -> Result>, AppError> { + + let results = UserService::get_open_friend_requests( + state, + &token.subject + ).await?; + + Ok(Json(results)) +} + +pub async fn handle_get_friends( + State(state): State>, + Extension(token): Extension>, +) -> Result>, AppError> { + + let results = UserService::get_friends(state, &token.subject).await?; + Ok(Json(results)) +} + +pub async fn handle_add_friend( + State(state): State>, + Path(user_id): Path, + Extension(token): Extension>, +) -> Result<(), AppError> { + + UserService::add_friend(state, token.subject, user_id).await?; + Ok(()) +} + + +pub async fn handle_accept_friend_request( + State(state): State>, + Path(sender_id): Path, + Extension(token): Extension>, +) -> Result<(), AppError> { + UserService::accept_friend_request(state, token.subject, sender_id).await?; + Ok(()) +} + +pub async fn handle_reject_friend_request( + State(state): State>, + Path(sender_id): Path, + Extension(token): Extension>, +) -> Result<(), AppError> { + UserService::reject_friend_request(state, token.subject, sender_id).await?; + Ok(()) +} + +pub async fn handle_remove_friend( + State(state): State>, + Path(friend_id): Path, + Extension(token): Extension>, +) -> Result<(), AppError> { + UserService::remove_friend(state, token.subject, friend_id).await?; + Ok(()) +} + +pub async fn handle_ignore_user( + State(state): State>, + Path(user_id): Path, + Extension(token): Extension>, +)-> Result<(), AppError> { + UserService::ignore_user(state, token.subject, user_id).await?; + Ok(()) +} + +pub async fn handle_undo_ignore_user( + State(state): State>, + Path(user_id): Path, + Extension(token): Extension>, +)-> Result<(), AppError> { + UserService::undo_ignore(state, token.subject, user_id).await?; + Ok(()) +} \ No newline at end of file diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs new file mode 100644 index 0000000..f504f72 --- /dev/null +++ b/src/user_relationship/user_service.rs @@ -0,0 +1,368 @@ +use std::sync::Arc; +use chrono::Utc; +use uuid::Uuid; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::broadcast::NotificationEvent::{FriendRequestAccepted, FriendRequestReceived}; +use crate::core::AppState; +use crate::core::cursor::{encode_cursor, CursorResults}; +use crate::errors::{AppError}; +use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationship, UserWithRelationshipDto}; +use crate::user_relationship::utils::resolve_relationship_state; + +pub struct UserService; + +impl UserService { + + /// Asynchronously queries a list of users based on a given username query, including their relationship type with the current user. + /// + /// This function fetches users whose names match the given `username_query` and paginates the results based on the supplied `cursor`. + /// The results returned are wrapped in a `CursorResults` structure, facilitating pagination with cursors. + /// + /// # Pagination Behavior + /// - A fixed page size of 20 is used for each query. An additional record is fetched to determine if there are more results beyond the current page. + /// - If more than `page_size` results are retrieved, the last record (used to identify the continuation cursor) is removed before returning the page content. + /// + pub async fn query_user_by_name( + state: Arc, + current_user_id: &Uuid, + username_query: &str, + cursor: UserPaginationCursor + ) -> Result, AppError> { + + let page_size: usize = 20; + let query_page_size = page_size + 1; + + let mut users = state.user_repository + .find_user_by_name_with_relationship_type(current_user_id, username_query, query_page_size as i64, cursor) + .await?; + + let next_cursor_string = if users.len() > page_size { + users.pop(); + users.last().map(|last_user| { + let next_page_cursor_struct = UserPaginationCursor { + last_seen_id: Some(last_user.r_user.id.clone()), + last_seen_name: Some(last_user.r_user.display_name.clone()), + }; + encode_cursor(&next_page_cursor_struct).map_err(|e| AppError::ProcessingError(format!("Cursor encoding failed: {}", e))) + }).transpose()? + } else { + None + }; + + let mapped_users = users.iter().map(|item| { + UserWithRelationshipDto { + user: item.r_user.clone(), + relationship_type: resolve_relationship_state(current_user_id, item.get_relationship()), + } + }).collect(); + + Ok(CursorResults { + next_cursor: next_cursor_string, + content: mapped_users, + }) + } + + pub async fn query_user_by_id( + state: Arc, + current_user_id: &Uuid, + user_id: &Uuid, + ) -> Result { + + let db_user = state + .user_repository + .find_user_by_id_with_relationship_type(current_user_id, user_id) + .await?; + + let user = db_user.ok_or_else(|| { + AppError::NotFound(format!("User with ID {} not found.", user_id)) + })?; + + let response_dto = UserWithRelationshipDto { + user: user.r_user.clone(), + relationship_type: resolve_relationship_state(current_user_id, user.get_relationship()), + }; + + Ok(response_dto) + } + + pub async fn get_open_friend_requests( + state: Arc, + current_user_id: &Uuid, + ) -> Result, AppError> { + let users = state.user_repository.select_open_friend_requests(current_user_id).await?; + Ok(users) + } + + pub async fn get_friends( + state: Arc, + current_user_id: &Uuid, + ) -> Result, AppError> { + let users = state.user_repository.find_users_with_specific_relationship(current_user_id, RelationshipState::FRIEND).await?; + Ok(users) + } + + pub async fn add_friend( + state: Arc, + sender_id: Uuid, + receiver_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state.user_repository.search_for_relationship(&mut tx, &sender_id, &receiver_id).await?; + if relationship.is_some() { //don't handle this request further when the users are in a relationship + return match relationship.unwrap().state { + RelationshipState::A_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), + RelationshipState::B_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), + RelationshipState::ALL_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), + RelationshipState::FRIEND => Ok(()), + RelationshipState::A_INVITED => Ok(()), + RelationshipState::B_INVITED => Ok(()), + } + } + let (user_a_id, user_b_id) = if sender_id < receiver_id { + (sender_id, receiver_id) + } else { + (receiver_id, sender_id) + }; + + let relationship_state = if sender_id == user_a_id { + RelationshipState::A_INVITED + } else { + RelationshipState::B_INVITED + }; + + let init_relationship = UserRelationship { + user_a_id, + user_b_id, + state: relationship_state, + relationship_change_timestamp: Utc::now(), + }; + + state.user_repository.insert_relationship(&mut tx, init_relationship).await?; + + tx.commit().await?; + let client_dto = state.user_repository.find_user_by_id(&sender_id).await?.ok_or_else(|| { + AppError::NotFound(format!("User with ID {} not found.", sender_id)) + })?; + BroadcastChannel::get().send_event( + Notification { + body: FriendRequestReceived {from_user: client_dto}, + created_at: Utc::now() + }, + &receiver_id + ).await; + Ok(()) + } + + pub async fn accept_friend_request( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + let is_accepter_user_a = client_id == relationship.user_a_id; + match (relationship.state, is_accepter_user_a) { + (RelationshipState::B_INVITED, true) => {}, //valid state + (RelationshipState::A_INVITED, false) => {}, //valid state + _ => { //everything else is invalid + return Err(AppError::ValidationError( + "Cannot accept this request. Invalid state or user.".to_string(), + )); + } + } + state.user_repository.update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::FRIEND + ).await?; + + state.user_repository.increment_friends_count(&mut tx, &relationship.user_a_id).await?; + state.user_repository.increment_friends_count(&mut tx, &relationship.user_b_id).await?; + tx.commit().await?; + + let client_dto = state.user_repository.find_user_by_id(&client_id).await?.ok_or_else(|| { + AppError::NotFound(format!("User with ID {} not found.", client_id)) + })?; + + BroadcastChannel::get().send_event( + Notification { + body: FriendRequestAccepted {from_user: client_dto}, + created_at: Utc::now() + }, + &sender_id + ).await; + + Ok(()) + } + + pub async fn reject_friend_request( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + let is_rejecter_user_a = client_id == relationship.user_a_id; + match (relationship.state.clone(), is_rejecter_user_a) { + (RelationshipState::B_INVITED, true) => {}, //valid state + (RelationshipState::A_INVITED, false) => {}, //valid state + _ => { //everything else is invalid + return Err(AppError::ValidationError( + "Cannot reject this request. Invalid state or user.".to_string(), + )); + } + } + state.user_repository.delete_relationship_state(&mut tx, relationship).await?; + Ok(()) + } + + pub async fn remove_friend( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + if relationship.state == RelationshipState::FRIEND { + state.user_repository.decrement_friends_count(&mut tx, &relationship.user_a_id).await?; + state.user_repository.decrement_friends_count(&mut tx, &relationship.user_b_id).await?; + state.user_repository.delete_relationship_state(&mut tx, relationship).await?; + tx.commit().await?; + } else { + return Err(AppError::ValidationError("These users aren't in a friend relationship.".to_string())); + } + Ok(()) + } + + pub async fn ignore_user( + state: Arc, + client_id: Uuid, + ignored_user_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &ignored_user_id).await?; + + if let Some(rel) = relationship { + + let is_client_user_a = client_id == rel.user_a_id; + + let new_state = match (rel.state, is_client_user_a) { + (RelationshipState::ALL_BLOCKED, _) => return Ok(()), //Both blocked + (RelationshipState::A_BLOCKED, true) => return Ok(()), //client is A and blocked B + (RelationshipState::B_BLOCKED, false) => return Ok(()), //client is B and blocked A + (RelationshipState::A_BLOCKED, false) => RelationshipState::ALL_BLOCKED, + (RelationshipState::B_BLOCKED, true) => RelationshipState::ALL_BLOCKED, + (RelationshipState::FRIEND, _) => { + state.user_repository.decrement_friends_count(&mut tx, &rel.user_a_id).await?; + state.user_repository.decrement_friends_count(&mut tx, &rel.user_b_id).await?; + + if is_client_user_a { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + } + }, + (RelationshipState::A_INVITED, _) | (RelationshipState::B_INVITED, _) => { + if is_client_user_a { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + } + } + }; + state.user_repository.update_relationship_state( + &mut tx, + &rel.user_a_id, + &rel.user_b_id, + new_state + ).await?; + } else { //no relationship found, create one + let (user_a_id, user_b_id) = if client_id < ignored_user_id { + (client_id, ignored_user_id) + } else { + (ignored_user_id, client_id) + }; + + let relationship_state = if client_id == user_a_id { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + }; + + let init_relationship = UserRelationship { + user_a_id, + user_b_id, + state: relationship_state, + relationship_change_timestamp: Utc::now(), + }; + state.user_repository.insert_relationship(&mut tx, init_relationship).await?; + } + + tx.commit().await?; + Ok(()) + } + + pub async fn undo_ignore( + state: Arc, + client_id: Uuid, + ignored_user_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &ignored_user_id) + .await? + .ok_or_else(|| { + AppError::NotFound("No block relationship found to undo.".to_string()) + })?; + let is_client_user_a = client_id == relationship.user_a_id; + match (relationship.state.clone(), is_client_user_a) { + (RelationshipState::ALL_BLOCKED, true) => { // Client was A, only B blocking now + state.user_repository.update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::B_BLOCKED, + ).await?; + }, + (RelationshipState::ALL_BLOCKED, false) => { // Client was B, only A blocking now + state.user_repository.update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::A_BLOCKED, + ).await?; + }, + + (RelationshipState::A_BLOCKED, true) | (RelationshipState::B_BLOCKED, false) => { // Fall 2: only client blocked, remove relationship + state.user_repository.delete_relationship_state( + &mut tx, + relationship + ).await?; + }, + (RelationshipState::A_BLOCKED, false) | (RelationshipState::B_BLOCKED, true) => { //client was blocked by another user + return Err(AppError::Blocked( + "You cannot undo a block placed on you by another user.".to_string(), + )); + }, + _ => { // some other state, no undo possible + return Err(AppError::ValidationError( + "No active block from your side found to undo.".to_string(), + )); + } + } + + Ok(()) + } + +} \ No newline at end of file diff --git a/src/user_relationship/utils.rs b/src/user_relationship/utils.rs index 452132c..54e4e39 100644 --- a/src/user_relationship/utils.rs +++ b/src/user_relationship/utils.rs @@ -1,8 +1,9 @@ use uuid::Uuid; use crate::user_relationship::model::{Relationship, RelationshipState, UserRelationship}; + pub fn resolve_relationship_state( - client_id: Uuid, + client_id: &Uuid, relationship: Option, ) -> Option { @@ -14,7 +15,7 @@ pub fn resolve_relationship_state( RelationshipState::FRIEND => Some(Relationship::Friend), RelationshipState::A_BLOCKED => { - if relationship.user_a_id == client_id { + if relationship.user_a_id == *client_id { Some(Relationship::ClientBlocked) } else { Some(Relationship::ClientGotBlocked) @@ -22,7 +23,7 @@ pub fn resolve_relationship_state( } RelationshipState::B_BLOCKED => { - if relationship.user_b_id == client_id { + if relationship.user_b_id == *client_id { Some(Relationship::ClientBlocked) } else { Some(Relationship::ClientGotBlocked) @@ -30,7 +31,7 @@ pub fn resolve_relationship_state( } RelationshipState::ALL_BLOCKED => { - if relationship.user_b_id == client_id || relationship.user_a_id == client_id { + if relationship.user_b_id == *client_id || relationship.user_a_id == *client_id { Some(Relationship::ClientBlocked) } else { Some(Relationship::ClientGotBlocked) @@ -38,7 +39,7 @@ pub fn resolve_relationship_state( } RelationshipState::A_INVITED => { - if relationship.user_a_id == client_id { + if relationship.user_a_id == *client_id { Some(Relationship::InviteSent) } else { Some(Relationship::InviteReceived) @@ -46,7 +47,7 @@ pub fn resolve_relationship_state( } RelationshipState::B_INVITED => { - if relationship.user_b_id == client_id { + if relationship.user_b_id == *client_id { Some(Relationship::InviteSent) } else { Some(Relationship::InviteReceived) diff --git a/src/utils.rs b/src/utils.rs index 3b26344..20e15ba 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -67,3 +67,5 @@ pub fn crop_image_from_center( } } } + + From 92fbbd0362b52c98b9e6e8e30822f69a9c2f9c9b Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 19:54:14 +0100 Subject: [PATCH 06/23] Refactoring Service Layer --- .idea/workspace.xml | 52 ++++--- src/broadcast/notification.rs | 3 +- src/database/message_database.rs | 2 +- src/errors.rs | 17 ++- src/messaging/handler.rs | 18 +++ src/messaging/message_service.rs | 114 ++++++++++++++ src/messaging/messages.rs | 144 ------------------ src/messaging/mod.rs | 6 +- src/{model/message.rs => messaging/model.rs} | 12 ++ src/messaging/notifications.rs | 43 +----- src/messaging/routes.rs | 9 +- src/model/mod.rs | 2 - src/repository/room_repository.rs | 3 +- src/repository/user_repository.rs | 18 +-- src/rooms/rooms.rs | 6 +- src/rooms/timeline.rs | 18 +-- .../{user_handler.rs => handler.rs} | 0 src/user_relationship/mod.rs | 2 +- src/user_relationship/model.rs | 75 ++++++++- src/user_relationship/routes.rs | 2 +- src/user_relationship/user_service.rs | 22 +-- src/user_relationship/utils.rs | 57 ------- src/utils.rs | 18 +-- 23 files changed, 297 insertions(+), 346 deletions(-) create mode 100644 src/messaging/handler.rs create mode 100644 src/messaging/message_service.rs delete mode 100644 src/messaging/messages.rs rename src/{model/message.rs => messaging/model.rs} (92%) rename src/user_relationship/{user_handler.rs => handler.rs} (100%) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 8349745..f4d4d8e 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,22 +12,28 @@ - - - - + + + - - - + + - + + + + + + + - + + - + + @@ -127,8 +133,8 @@ - + @@ -402,15 +408,7 @@ - - - - - @@ -817,7 +823,6 @@ - @@ -842,7 +847,8 @@ - diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index ebdbb08..23fde93 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -1,7 +1,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::{ChatRoom, MessageDTO}; +use crate::messaging::model::MessageDTO; +use crate::model::{ChatRoom}; use crate::user_relationship::model::User; diff --git a/src/database/message_database.rs b/src/database/message_database.rs index 8200c67..5d1b710 100644 --- a/src/database/message_database.rs +++ b/src/database/message_database.rs @@ -9,7 +9,7 @@ use scylla::client::session_builder::SessionBuilder; use scylla::errors::{ExecutionError, UseKeyspaceError}; use scylla::response::query_result::QueryResult; use uuid::Uuid; -use crate::model::Message; +use crate::messaging::model::Message; #[derive(Debug, Clone)] pub struct MessageDatabase { diff --git a/src/errors.rs b/src/errors.rs index 852d4c8..8422e1a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -122,7 +122,8 @@ pub enum AppError { /// Ein interner Fehler bei der Verarbeitung, z.B. beim Kodieren/Dekodieren. ProcessingError(String), - Blocked(String), + Blocked(String) + } impl fmt::Debug for AppError { @@ -144,7 +145,7 @@ impl Display for AppError { AppError::NotFound(msg) => write!(f, "Entity not found: {}", msg), AppError::DatabaseError(err) => write!(f, "Ein Datenbankfehler ist aufgetreten: {}", err), AppError::ProcessingError(msg) => write!(f, "Ein Verarbeitungsfehler ist aufgetreten: {}", msg), - AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), + AppError::Blocked(msg) => write!(f, "Blocked: {}", msg) } } } @@ -155,6 +156,12 @@ impl From for AppError { } } +impl From for AppError { + fn from(err: scylla::errors::ExecutionError) -> AppError { + AppError::DatabaseError(Box::new(err)) + } +} + impl Error for AppError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { @@ -178,8 +185,8 @@ impl IntoResponse for AppError { tracing::error!("Database error: {:?}", internal_err); HttpError::new( StatusCode::INTERNAL_SERVER_ERROR, - ErrorCode::ServiceUnavailable, - "Internal service outage." + ErrorCode::UnexpectedError, + "Internal Server Error. Try again." ) } AppError::ProcessingError(msg) => { @@ -192,7 +199,7 @@ impl IntoResponse for AppError { } AppError::Blocked(msg) => { HttpError::new( - StatusCode::FORBIDDEN, + StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions, msg ) diff --git a/src/messaging/handler.rs b/src/messaging/handler.rs new file mode 100644 index 0000000..3cb61ed --- /dev/null +++ b/src/messaging/handler.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; +use axum::{Extension, Json}; +use axum::extract::State; +use crate::core::AppState; +use crate::errors::AppError; +use crate::keycloak::decode::KeycloakToken; +use crate::messaging::message_service::MessageService; +use crate::messaging::model::{MessageDTO, NewMessage}; + +pub async fn handle_send_message( + State(state): State>, + Extension(token): Extension>, + Json(payload): Json +) -> Result, AppError> { + + let response_msg = MessageService::send_message(state, payload, token.subject).await?; + Ok(Json(response_msg)) +} \ No newline at end of file diff --git a/src/messaging/message_service.rs b/src/messaging/message_service.rs new file mode 100644 index 0000000..738de83 --- /dev/null +++ b/src/messaging/message_service.rs @@ -0,0 +1,114 @@ +use std::str::FromStr; +use std::sync::Arc; +use chrono::Utc; +use uuid::Uuid; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::broadcast::NotificationEvent::ChatMessage; +use crate::core::AppState; +use crate::errors::{AppError}; +use crate::messaging::model::{Message, MessageBody, MessageDTO, MsgType, NewMessage, NewMessageBody, NewReplyBody, RepliedMessageDetails, ReplyBody}; + +pub struct MessageService; + +impl MessageService { + + pub async fn send_message( + state: Arc, + message: NewMessage, + client_id: Uuid + ) -> Result { + + let users = state.room_repository.select_room_participants_ids(&message.chat_room_id).await?; + if !users.contains(&client_id) { + return Err(AppError::Blocked("User has not access to this room.".to_string())); + }; + + let msg_body = match message.msg_body.clone() { + NewMessageBody::Text(text) => { + MessageBody::Text(text) + } + NewMessageBody::Media(media) => { + MessageBody::Media(media) + } + NewMessageBody::Reply(reply) => { + let reply = MessageService::create_reply_message(&reply, &state, &message.chat_room_id).await.map_err(|err| { + AppError::ProcessingError(format!("Can't create reply message: {}", err.to_string())) + })?; + MessageBody::Reply(reply) + } + }; + + let msg = Message::new(message.chat_room_id, client_id, msg_body).map_err(|_err| { + AppError::ProcessingError("Can't create chat message.".to_string()) + })?; + + state.message_repository.insert_data(msg.clone()).await?; + + let mut tx = state.room_repository.start_transaction().await?; + let displayed = state.room_repository.update_last_room_message(&mut *tx, &message.chat_room_id, &msg.sender_id, MessageService::generate_room_preview_text(&message)).await?; + state.room_repository.update_user_read_status(&mut *tx, &message.chat_room_id, &msg.sender_id).await?; + tx.commit().await?; + + + let mapped_msg = msg.to_dto().map_err(|err| { + AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) + })?; + + BroadcastChannel::get().send_event_to_all( + users, + Notification { + body: ChatMessage {message: mapped_msg.clone(), display_value: displayed }, + created_at: Utc::now() + } + ).await; + Ok(mapped_msg) + } + + async fn create_reply_message(msg: &NewReplyBody, state: &Arc, room_id: &Uuid) -> Result> { + let replied_to = state.message_repository.fetch_specific_message(&msg.reply_msg_id, room_id, &msg.reply_created_at).await?; + + let replied_body: MessageBody = serde_json::from_str(&replied_to.msg_body)?; + + let details = match replied_body { + MessageBody::Text(text) => { + RepliedMessageDetails::Text(text) + } + MessageBody::Media(media) => { + RepliedMessageDetails::Media(media) + } + MessageBody::Reply(reply) => { + RepliedMessageDetails::Reply {reply_text: reply.reply_text} + } + _ => { + return Err(Box::from("Unknown Reply body")) + } + }; + + let new_body = ReplyBody { + reply_msg_id: replied_to.message_id, + reply_sender_id: replied_to.sender_id, + reply_msg_type: MsgType::from_str(&replied_to.msg_type)?, + reply_created_at: replied_to.created_at, + reply_msg_details: details, + reply_text: msg.reply_text.clone(), + }; + Ok(new_body) + } + + fn generate_room_preview_text(msg: &NewMessage) -> String { + match &msg.msg_body { + NewMessageBody::Text(body) => { + format!(": {}", body.text) + } + NewMessageBody::Media(_) => { + String::from(" hat etwas geteilt.") + } + NewMessageBody::Reply(_) => { + String::from(" hat auf eine Nachricht geantwortet.") + } + } + } + + + +} \ No newline at end of file diff --git a/src/messaging/messages.rs b/src/messaging/messages.rs deleted file mode 100644 index 6b05e20..0000000 --- a/src/messaging/messages.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::str::FromStr; -use std::sync::Arc; -use axum::{Extension, Json}; -use axum::extract::State; -use axum::response::IntoResponse; -use chrono::Utc; -use http::{StatusCode}; -use log::error; -use uuid::Uuid; -use crate::errors::{ErrorCode, HttpError}; -use crate::rooms::timeline::msg_to_dto; -use crate::broadcast::{BroadcastChannel, Notification}; -use crate::broadcast::NotificationEvent::ChatMessage; -use crate::core::AppState; -use crate::keycloak::decode::KeycloakToken; -use crate::model::{Message, MessageBody, MsgType, NewMessage, NewMessageBody, NewReplyBody, RepliedMessageDetails, ReplyBody}; - - -pub async fn send_message( - Extension(token): Extension>, - State(state): State>, - Json(payload): Json -) -> impl IntoResponse { - //validate if the user is in the room - let users = match state.room_repository.select_room_participants_ids(&payload.chat_room_id).await { - Ok(ids) => ids, - Err(error) => { - error!("{}", error.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't fetch room participants.").into_response(); - } - }; - if !users.contains(&token.subject) { - return HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions, "Room not found or access denied.").into_response(); - } - - - let msg_body = match payload.msg_body.clone() { - NewMessageBody::Text(text) => { - MessageBody::Text(text) - } - NewMessageBody::Media(media) => { - MessageBody::Media(media) - } - NewMessageBody::Reply(reply) => { - let reply = match handle_reply_message(&reply, &state, &payload.chat_room_id).await { - Ok(reply) => reply, - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't handle reply message.").into_response(); - } - }; - MessageBody::Reply(reply) - } - }; - - let msg = match Message::new(payload.chat_room_id, token.subject, msg_body) { - Ok(message) => message, - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't serialize message.").into_response(); - } - }; - - - if let Err(err) = state.message_repository.insert_data(msg.clone()).await { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't safe message in timeline.").into_response(); - } - - let mut tx = state.room_repository.start_transaction().await.unwrap(); - let displayed = match state.room_repository.update_last_room_message(&mut *tx, &payload.chat_room_id, &msg.sender_id, generate_room_preview_text(&payload)).await { - Ok(displayed) => displayed, - Err(error) => { - error!("{}", error); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't update the state of the chat room.").into_response(); - } - }; - if let Err(err) = state.room_repository.update_user_read_status(&mut *tx, &payload.chat_room_id, &msg.sender_id).await { - error!("{}", err); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't update user read status.").into_response(); - } - tx.commit().await.unwrap(); - - let mapped_msg = match msg_to_dto(msg) { - Ok(msg) => msg, - Err(err) => { - return HttpError::bad_request(ErrorCode::UnexpectedError,format!("Can't serialize message: {}", err)).into_response(); - } - }; - - BroadcastChannel::get().send_event_to_all( - users, - Notification { - body: ChatMessage {message: mapped_msg.clone(), display_value: displayed }, - created_at: Utc::now() - } - ).await; - (StatusCode::CREATED, Json(mapped_msg)).into_response() -} - -async fn handle_reply_message(msg: &NewReplyBody, state: &Arc, room_id: &Uuid) -> Result> { - let replied_to = state.message_repository.fetch_specific_message(&msg.reply_msg_id, room_id, &msg.reply_created_at).await?; - - let replied_body: MessageBody = serde_json::from_str(&replied_to.msg_body)?; - - let details = match replied_body { - MessageBody::Text(text) => { - RepliedMessageDetails::Text(text) - } - MessageBody::Media(media) => { - RepliedMessageDetails::Media(media) - } - MessageBody::Reply(reply) => { - RepliedMessageDetails::Reply {reply_text: reply.reply_text} - } - _ => { - return Err(Box::from("Unknown Reply body")) - } - }; - - let new_body = ReplyBody { - reply_msg_id: replied_to.message_id, - reply_sender_id: replied_to.sender_id, - reply_msg_type: MsgType::from_str(&replied_to.msg_type)?, - reply_created_at: replied_to.created_at, - reply_msg_details: details, - reply_text: msg.reply_text.clone(), - }; - Ok(new_body) -} - -fn generate_room_preview_text(msg: &NewMessage) -> String { - match &msg.msg_body { - NewMessageBody::Text(body) => { - format!(": {}", body.text) - } - NewMessageBody::Media(_) => { - String::from(" hat etwas geteilt.") - } - NewMessageBody::Reply(_) => { - String::from(" hat auf eine Nachricht geantwortet.") - } - } -} \ No newline at end of file diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs index da7980a..4946201 100644 --- a/src/messaging/mod.rs +++ b/src/messaging/mod.rs @@ -1,3 +1,5 @@ -mod messages; mod notifications; -pub mod routes; \ No newline at end of file +pub mod routes; +mod handler; +mod message_service; +pub mod model; diff --git a/src/model/message.rs b/src/messaging/model.rs similarity index 92% rename from src/model/message.rs rename to src/messaging/model.rs index 209b426..cd58ce8 100644 --- a/src/model/message.rs +++ b/src/messaging/model.rs @@ -49,6 +49,18 @@ impl Message { Ok(msg) } + pub fn to_dto(&self) -> Result> { + let message = MessageDTO { + chat_room_id: self.chat_room_id, + message_id: self.message_id, + sender_id: self.sender_id, + msg_body: serde_json::from_str(&self.msg_body)?, + msg_type: self.msg_type.parse()?, + created_at: self.created_at + }; + Ok(message) + } + } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/messaging/notifications.rs b/src/messaging/notifications.rs index 371ea52..edfd229 100644 --- a/src/messaging/notifications.rs +++ b/src/messaging/notifications.rs @@ -1,17 +1,12 @@ -use std::sync::Arc; use std::time::Duration; use axum::{Extension, Json}; -use axum::extract::State; use axum::response::{IntoResponse, Sse}; use axum::response::sse::Event; use futures::Stream; -use http::StatusCode; use log::error; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; -use crate::errors::{ErrorCode, HttpError}; -use crate::broadcast::{BroadcastChannel, SendNotification}; -use crate::core::AppState; +use crate::broadcast::{BroadcastChannel}; use crate::keycloak::decode::KeycloakToken; @@ -42,38 +37,8 @@ pub async fn stream_server_events( ) } -//todo: query latest events -pub async fn poll_for_new_notifications() -> impl IntoResponse { +pub async fn get_latest_notification_events() -> impl IntoResponse { + //todo: query latest events //placeholder Json::>(vec![]).into_response() -} - - -pub async fn add_notification( - State(state): State>, - Extension(token): Extension>, - Json(payload): Json, -) -> impl IntoResponse { - - let client = match state.env.token_issuer.valid_admin_client.clone() { - Some(client) => client, - None => { - return HttpError::new( - StatusCode::UNAUTHORIZED, - ErrorCode::InsufficientPermissions, - "A valid admin client is not provided." - ).into_response() - } - }; - - if token.authorized_party != client { - return HttpError::new( - StatusCode::UNAUTHORIZED, - ErrorCode::InsufficientPermissions, - "This client is not allowed to add a notification!" - ).into_response() - } - - BroadcastChannel::get().send_event(payload.body, &payload.to_user).await; - StatusCode::OK.into_response() -} +} \ No newline at end of file diff --git a/src/messaging/routes.rs b/src/messaging/routes.rs index 8d1b96b..1ef4eeb 100644 --- a/src/messaging/routes.rs +++ b/src/messaging/routes.rs @@ -2,13 +2,12 @@ use std::sync::Arc; use axum::Router; use axum::routing::{get, post}; use crate::core::AppState; -use crate::messaging::messages::send_message; -use crate::messaging::notifications::{add_notification, poll_for_new_notifications, stream_server_events}; +use crate::messaging::handler::handle_send_message; +use crate::messaging::notifications::{get_latest_notification_events, stream_server_events}; pub fn create_messaging_routes() -> Router> { Router::new() //add new routes here - .route("/api/notify", get(poll_for_new_notifications)) + .route("/api/notifications", get(get_latest_notification_events)) .route("/api/sse", get(stream_server_events)) - .route("/api/notify", post(add_notification)) - .route("/api/send-msg", post(send_message)) + .route("/api/send-msg", post(handle_send_message)) } \ No newline at end of file diff --git a/src/model/mod.rs b/src/model/mod.rs index 6f3de0e..b0ff45b 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,4 @@ mod room; -mod message; pub mod room_member; mod response_utils; @@ -7,6 +6,5 @@ mod queries; pub use room_member::*; pub use room::*; -pub use message::*; pub use response_utils::*; pub use queries::*; diff --git a/src/repository/room_repository.rs b/src/repository/room_repository.rs index d4751f6..14c9f64 100644 --- a/src/repository/room_repository.rs +++ b/src/repository/room_repository.rs @@ -1,6 +1,5 @@ use chrono::Utc; use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; -use sqlx::error::BoxDynError; use uuid::Uuid; use crate::model::room_member::{RoomMember, MembershipStatus}; use crate::model::{ChatRoomEntity, ChatRoom, NewRoom, RoomType}; @@ -270,7 +269,7 @@ impl RoomRepository { /// Like this: state.room_repository.get_connection().acquire().await.unwrap(); /// /// [workaround]: https://github.com/launchbadge/sqlx/issues/1015#issuecomment-767787777 - pub async fn update_last_room_message(&self, conn: &mut PgConnection, room_id: &Uuid, sender_id: &Uuid, preview_text: String) -> Result + pub async fn update_last_room_message(&self, conn: &mut PgConnection, room_id: &Uuid, sender_id: &Uuid, preview_text: String) -> Result { let name = sqlx::query!("SELECT display_name FROM app_user WHERE id = $1", sender_id).fetch_one(&mut *conn).await?; let text = format!("{}{}", name.display_name, preview_text); diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index caa2e0e..d2659b3 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,6 +1,6 @@ use sqlx::{query_as, Error, PgConnection, Pool, Postgres, Transaction}; use uuid::Uuid; -use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationship, UserWithRelationship}; +use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipEntity}; #[derive(Clone)] pub struct UserRepository { @@ -18,8 +18,8 @@ impl UserRepository { Ok(tx) } - pub async fn find_user_by_id_with_relationship_type(&self, client_id: &Uuid, searched_user_id: &Uuid) -> Result, Error> { - let user = query_as::<_, UserWithRelationship>( + pub async fn find_user_by_id_with_relationship_type(&self, client_id: &Uuid, searched_user_id: &Uuid) -> Result, Error> { + let user = query_as::<_, UserWithRelationshipEntity>( r#"SELECT r_user.id, r_user.display_name, @@ -61,8 +61,8 @@ impl UserRepository { Ok(user) } - pub async fn find_user_by_name_with_relationship_type(&self, client_id: &Uuid, username: &str, page_size: i64, cursor: UserPaginationCursor) -> Result, Error> { - let user = query_as::<_, UserWithRelationship>( + pub async fn find_user_by_name_with_relationship_type(&self, client_id: &Uuid, username: &str, page_size: i64, cursor: UserPaginationCursor) -> Result, Error> { + let user = query_as::<_, UserWithRelationshipEntity>( r#"SELECT r_user.id, r_user.display_name, @@ -149,10 +149,10 @@ impl UserRepository { Ok(users) } - pub async fn search_for_relationship(&self, conn: &mut PgConnection, client_id: &Uuid, other_id: &Uuid) -> Result, Error> + pub async fn search_for_relationship(&self, conn: &mut PgConnection, client_id: &Uuid, other_id: &Uuid) -> Result, Error> { let relationship = sqlx::query_as!( - UserRelationship, + UserRelationshipEntity, r#" SELECT ur.user_a_id, @@ -169,7 +169,7 @@ impl UserRepository { Ok(relationship) } - pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: UserRelationship) -> Result<(), Error> { + pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: UserRelationshipEntity) -> Result<(), Error> { sqlx::query!( r#" INSERT INTO user_relationship (user_a_id, user_b_id, state, relationship_change_timestamp) @@ -206,7 +206,7 @@ impl UserRepository { pub async fn delete_relationship_state( &self, conn: &mut PgConnection, - user_relationship: UserRelationship + user_relationship: UserRelationshipEntity ) -> Result<(), sqlx::Error> { sqlx::query!( r#" diff --git a/src/rooms/rooms.rs b/src/rooms/rooms.rs index 28b4850..1be9eb9 100644 --- a/src/rooms/rooms.rs +++ b/src/rooms/rooms.rs @@ -8,13 +8,13 @@ use log::{error, info}; use uuid::Uuid; use bytes::Bytes; use crate::errors::{ErrorCode, HttpError}; -use crate::rooms::timeline::{msg_to_dto}; use crate::keycloak::decode::KeycloakToken; -use crate::model::{ChatRoomWithUserDTO, MembershipStatus, Message, MessageBody, NewRoom as UploadRoom, RoomType, RoomChangeBody, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; +use crate::model::{ChatRoomWithUserDTO, MembershipStatus, NewRoom as UploadRoom, RoomType, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; use crate::utils::{check_user_in_room, crop_image_from_center}; use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; use crate::core::AppState; +use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; pub async fn get_users_in_room( @@ -390,7 +390,7 @@ async fn save_message_and_broadcast(message: Message, state: &Arc, to_ return HttpError::bad_request(ErrorCode::UnexpectedError,"Unable to persist the message.").into_response(); }; - let mapped_msg = match msg_to_dto(message) { + let mapped_msg = match message.to_dto() { Ok(msg) => msg, Err(err) => { return HttpError::bad_request(ErrorCode::UnexpectedError,format!("Can't serialize message: {}", err)).into_response() diff --git a/src/rooms/timeline.rs b/src/rooms/timeline.rs index fce521e..b5161b8 100644 --- a/src/rooms/timeline.rs +++ b/src/rooms/timeline.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use std::sync::Arc; use axum::{Extension, Json}; use axum::extract::{Path, Query, State}; @@ -11,7 +10,8 @@ use crate::errors::{ErrorCode, HttpError}; use crate::utils::{check_user_in_room}; use crate::core::AppState; use crate::keycloak::decode::KeycloakToken; -use crate::model::{Message, MessageDTO, MsgType}; +use crate::messaging::model::MessageDTO; + #[derive(Deserialize)] pub struct TimelineQuery { @@ -32,7 +32,7 @@ pub async fn scroll_chat_timeline( Ok(data) => { let mut mapped: Vec = vec![]; data.into_iter().for_each(|message| { - match msg_to_dto(message) { + match message.to_dto() { Ok(dto) => mapped.push(dto), Err(err) => { error!("Failed to convert message to DTO: {}", err); @@ -46,16 +46,4 @@ pub async fn scroll_chat_timeline( HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to fetch message data.").into_response() } } -} - -pub fn msg_to_dto(message: Message) -> Result> { - let msg = MessageDTO { - chat_room_id: message.chat_room_id, - message_id: message.message_id, - sender_id: message.sender_id, - msg_body: serde_json::from_str(&message.msg_body)?, - msg_type: MsgType::from_str(&message.msg_type)?, - created_at: message.created_at, - }; - Ok(msg) } \ No newline at end of file diff --git a/src/user_relationship/user_handler.rs b/src/user_relationship/handler.rs similarity index 100% rename from src/user_relationship/user_handler.rs rename to src/user_relationship/handler.rs diff --git a/src/user_relationship/mod.rs b/src/user_relationship/mod.rs index 2ec2e08..58823b0 100644 --- a/src/user_relationship/mod.rs +++ b/src/user_relationship/mod.rs @@ -1,6 +1,6 @@ pub mod model; mod utils; -mod user_handler; +mod handler; pub mod routes; mod query_param; mod user_service; \ No newline at end of file diff --git a/src/user_relationship/model.rs b/src/user_relationship/model.rs index 760e121..785b69f 100644 --- a/src/user_relationship/model.rs +++ b/src/user_relationship/model.rs @@ -14,7 +14,7 @@ pub struct FriendRequestResult { } #[derive(Debug, Clone)] -pub struct UserRelationship { +pub struct UserRelationshipEntity { pub user_a_id: Uuid, pub user_b_id: Uuid, pub state: RelationshipState, @@ -22,7 +22,7 @@ pub struct UserRelationship { } #[derive(Debug)] -pub struct UserWithRelationship { +pub struct UserWithRelationshipEntity { pub r_user: User, user_a_id: Option, user_b_id: Option, @@ -30,10 +30,11 @@ pub struct UserWithRelationship { relationship_change_timestamp: Option>, } -impl UserWithRelationship { - pub fn get_relationship(&self) -> Option { +impl UserWithRelationshipEntity { + + pub fn get_relationship(&self) -> Option { if self.user_a_id.is_some() && self.user_b_id.is_some() && self.relationship_state.is_some() && self.relationship_change_timestamp.is_some() { - Some(UserRelationship { + Some(UserRelationshipEntity { user_a_id: self.user_a_id.unwrap(), user_b_id: self.user_b_id.unwrap(), state: self.relationship_state.clone().unwrap(), @@ -43,9 +44,69 @@ impl UserWithRelationship { None } } + + pub fn to_dto(&self, client_id: &Uuid) -> UserWithRelationshipDto { + UserWithRelationshipDto { + user: self.r_user.clone(), + relationship_type: self.resolve_relationship_state(client_id), + } + } + + pub fn resolve_relationship_state( + &self, + client_id: &Uuid + ) -> Option { + + let relationship = self.get_relationship()?; + + match relationship.state { + + RelationshipState::FRIEND => Some(Relationship::Friend), + + RelationshipState::A_BLOCKED => { + if relationship.user_a_id == *client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::B_BLOCKED => { + if relationship.user_b_id == *client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::ALL_BLOCKED => { + if relationship.user_b_id == *client_id || relationship.user_a_id == *client_id { + Some(Relationship::ClientBlocked) + } else { + Some(Relationship::ClientGotBlocked) + } + } + + RelationshipState::A_INVITED => { + if relationship.user_a_id == *client_id { + Some(Relationship::InviteSent) + } else { + Some(Relationship::InviteReceived) + } + } + + RelationshipState::B_INVITED => { + if relationship.user_b_id == *client_id { + Some(Relationship::InviteSent) + } else { + Some(Relationship::InviteReceived) + } + } + } + } } -impl<'r, R: Row> FromRow<'r, R> for UserWithRelationship +impl<'r, R: Row> FromRow<'r, R> for UserWithRelationshipEntity where &'r str: sqlx::ColumnIndex, Uuid: sqlx::Decode<'r, R::Database> + sqlx::Type, @@ -68,7 +129,7 @@ where let user_b_id = row.try_get("user_b_id")?; let relationship_change_timestamp = row.try_get("relationship_change_timestamp")?; - Ok(UserWithRelationship { + Ok(UserWithRelationshipEntity { r_user, user_a_id, user_b_id, diff --git a/src/user_relationship/routes.rs b/src/user_relationship/routes.rs index 0b1e203..ffc9835 100644 --- a/src/user_relationship/routes.rs +++ b/src/user_relationship/routes.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::Router; use axum::routing::{delete, get, post}; use crate::core::AppState; -use crate::user_relationship::user_handler::{handle_accept_friend_request, handle_add_friend, handle_get_friends, handle_get_open_friend_requests, handle_ignore_user, handle_reject_friend_request, handle_remove_friend, handle_search_user_by_id, handle_search_user_by_name, handle_undo_ignore_user}; +use crate::user_relationship::handler::{handle_accept_friend_request, handle_add_friend, handle_get_friends, handle_get_open_friend_requests, handle_ignore_user, handle_reject_friend_request, handle_remove_friend, handle_search_user_by_id, handle_search_user_by_name, handle_undo_ignore_user}; pub fn create_user_routes() -> Router> { diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs index f504f72..0cced84 100644 --- a/src/user_relationship/user_service.rs +++ b/src/user_relationship/user_service.rs @@ -6,8 +6,8 @@ use crate::broadcast::NotificationEvent::{FriendRequestAccepted, FriendRequestRe use crate::core::AppState; use crate::core::cursor::{encode_cursor, CursorResults}; use crate::errors::{AppError}; -use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationship, UserWithRelationshipDto}; -use crate::user_relationship::utils::resolve_relationship_state; +use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipDto}; + pub struct UserService; @@ -50,10 +50,7 @@ impl UserService { }; let mapped_users = users.iter().map(|item| { - UserWithRelationshipDto { - user: item.r_user.clone(), - relationship_type: resolve_relationship_state(current_user_id, item.get_relationship()), - } + item.to_dto(current_user_id) }).collect(); Ok(CursorResults { @@ -76,13 +73,8 @@ impl UserService { let user = db_user.ok_or_else(|| { AppError::NotFound(format!("User with ID {} not found.", user_id)) })?; - - let response_dto = UserWithRelationshipDto { - user: user.r_user.clone(), - relationship_type: resolve_relationship_state(current_user_id, user.get_relationship()), - }; - - Ok(response_dto) + + Ok(user.to_dto(current_user_id)) } pub async fn get_open_friend_requests( @@ -130,7 +122,7 @@ impl UserService { RelationshipState::B_INVITED }; - let init_relationship = UserRelationship { + let init_relationship = UserRelationshipEntity { user_a_id, user_b_id, state: relationship_state, @@ -299,7 +291,7 @@ impl UserService { RelationshipState::B_BLOCKED }; - let init_relationship = UserRelationship { + let init_relationship = UserRelationshipEntity { user_a_id, user_b_id, state: relationship_state, diff --git a/src/user_relationship/utils.rs b/src/user_relationship/utils.rs index 54e4e39..e69de29 100644 --- a/src/user_relationship/utils.rs +++ b/src/user_relationship/utils.rs @@ -1,57 +0,0 @@ -use uuid::Uuid; -use crate::user_relationship::model::{Relationship, RelationshipState, UserRelationship}; - - -pub fn resolve_relationship_state( - client_id: &Uuid, - relationship: Option, -) -> Option { - - let relationship = relationship?; - - - match relationship.state { - - RelationshipState::FRIEND => Some(Relationship::Friend), - - RelationshipState::A_BLOCKED => { - if relationship.user_a_id == *client_id { - Some(Relationship::ClientBlocked) - } else { - Some(Relationship::ClientGotBlocked) - } - } - - RelationshipState::B_BLOCKED => { - if relationship.user_b_id == *client_id { - Some(Relationship::ClientBlocked) - } else { - Some(Relationship::ClientGotBlocked) - } - } - - RelationshipState::ALL_BLOCKED => { - if relationship.user_b_id == *client_id || relationship.user_a_id == *client_id { - Some(Relationship::ClientBlocked) - } else { - Some(Relationship::ClientGotBlocked) - } - } - - RelationshipState::A_INVITED => { - if relationship.user_a_id == *client_id { - Some(Relationship::InviteSent) - } else { - Some(Relationship::InviteReceived) - } - } - - RelationshipState::B_INVITED => { - if relationship.user_b_id == *client_id { - Some(Relationship::InviteSent) - } else { - Some(Relationship::InviteReceived) - } - } - } -} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs index 20e15ba..1b44cb9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,7 +5,7 @@ use std::io::Cursor; use http::StatusCode; use image::GenericImageView; use log::error; -use crate::errors::{ErrorCode, HttpError}; +use crate::errors::{AppError, ErrorCode, HttpError}; use crate::core::AppState; @@ -13,22 +13,12 @@ pub async fn check_user_in_room( state: &Arc, user_id: &Uuid, room_id: &Uuid, -) -> Result<(), HttpError> { - let is_in = match state - .room_repository - .is_user_in_room(user_id, room_id) - .await { - Ok(is_in) => is_in, - Err(err) => { - error!("{}", err); - return Err(HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::UnexpectedError, "Unable to check if user is in room")) - } - }; - +) -> Result<(), AppError> { + let is_in = state.room_repository.is_user_in_room(user_id, room_id).await?; if is_in { Ok(()) } else { - Err(HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions, "Unable to check if user is in room")) + Err(AppError::Blocked("Invalid permissions to interact with this room".to_string())) } } From 1616d7617c5c3197fbaf4b62f1356440cd2986c2 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 21:52:08 +0100 Subject: [PATCH 07/23] Modularize rooms service and handler logic --- .idea/workspace.xml | 51 ++- src/database/message_database.rs | 3 +- src/errors.rs | 15 +- src/rooms/handler.rs | 182 ++++++++++ src/rooms/mod.rs | 5 +- src/rooms/room_service.rs | 286 +++++++++++++++ src/rooms/rooms.rs | 484 -------------------------- src/rooms/routes.rs | 24 +- src/rooms/timeline.rs | 49 --- src/rooms/timeline_service.rs | 33 ++ src/user_relationship/user_service.rs | 6 +- src/utils.rs | 18 +- 12 files changed, 559 insertions(+), 597 deletions(-) create mode 100644 src/rooms/handler.rs create mode 100644 src/rooms/room_service.rs delete mode 100644 src/rooms/rooms.rs delete mode 100644 src/rooms/timeline.rs create mode 100644 src/rooms/timeline_service.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index f4d4d8e..5ca1b39 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,29 +12,18 @@ - - - + + + + - - - - - - - - - - - - - - - + + + + - @@ -823,7 +812,6 @@ - @@ -848,7 +836,8 @@ - diff --git a/src/database/message_database.rs b/src/database/message_database.rs index 5d1b710..64f8de1 100644 --- a/src/database/message_database.rs +++ b/src/database/message_database.rs @@ -1,3 +1,4 @@ +use std::error::Error; use std::sync::Arc; use chrono::{DateTime, Utc}; use crate::core::{MessageDbConfig}; @@ -44,7 +45,7 @@ impl MessageDatabase { repository } - pub async fn fetch_data(&self, timestamp: DateTime, room_id: Uuid) -> Result, Box> { + pub async fn fetch_data(&self, timestamp: DateTime, room_id: Uuid) -> Result, Box> { let session = self.session.clone(); let mut iter: TypedRowStream = session.query_iter("SELECT chat_room_id, message_id, sender_id, msg_body, created_at, msg_type FROM chat_messages WHERE chat_room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 25", (room_id, timestamp)) .await?.rows_stream::()?; diff --git a/src/errors.rs b/src/errors.rs index 8422e1a..688c07d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -122,7 +122,9 @@ pub enum AppError { /// Ein interner Fehler bei der Verarbeitung, z.B. beim Kodieren/Dekodieren. ProcessingError(String), - Blocked(String) + Blocked(String), + + S3Error(String), } @@ -134,6 +136,7 @@ impl fmt::Debug for AppError { Self::DatabaseError(err) => write!(f, "DatabaseError: {}", err), Self::ProcessingError(msg) => write!(f, "ProcessingError: {}", msg), Self::Blocked(msg) => write!(f, "Blocked: {}", msg), + Self::S3Error(msg) => write!(f, "S3Error: {}", msg), } } } @@ -145,7 +148,8 @@ impl Display for AppError { AppError::NotFound(msg) => write!(f, "Entity not found: {}", msg), AppError::DatabaseError(err) => write!(f, "Ein Datenbankfehler ist aufgetreten: {}", err), AppError::ProcessingError(msg) => write!(f, "Ein Verarbeitungsfehler ist aufgetreten: {}", msg), - AppError::Blocked(msg) => write!(f, "Blocked: {}", msg) + AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), + AppError::S3Error(msg) => write!(f, "S3Error: {}", msg), } } } @@ -204,6 +208,13 @@ impl IntoResponse for AppError { msg ) } + AppError::S3Error(msg) => { + HttpError::new( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::UnexpectedError, + msg + ) + } }; http_error.into_response() diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs new file mode 100644 index 0000000..67b2ba7 --- /dev/null +++ b/src/rooms/handler.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; +use axum::{Extension, Json}; +use axum::extract::{Multipart, Path, Query, State}; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use log::error; +use serde::Deserialize; +use uuid::Uuid; +use crate::core::AppState; +use crate::errors::{AppError}; +use crate::keycloak::decode::KeycloakToken; +use crate::messaging::model::MessageDTO; +use crate::model::{ChatRoom, ChatRoomWithUserDTO, NewRoom, RoomMember, RoomType, UploadResponse}; +use crate::rooms::room_service::RoomService; +use crate::rooms::timeline_service::TimelineService; +use crate::utils::check_user_in_room; + +#[derive(Deserialize, Debug)] +pub struct RoomSearchQueryParam { + #[serde(rename = "withUser")] + pub with_user: Uuid +} + +#[derive(Deserialize)] +pub struct TimelineQueryParam { + timestamp: DateTime +} + +pub async fn handle_scroll_chat_timeline( + Extension(token): Extension>, + State(state): State>, + Path(room_id): Path, + Query(params): Query +) -> Result>, AppError> { + + check_user_in_room(&state, &token.subject, &room_id).await?; + let messages = TimelineService::scroll_chat_timeline(state, room_id, params.timestamp).await?; + Ok(Json(messages)) +} + +pub async fn handle_get_users_in_room( + State(state): State>, + Extension(token): Extension>, + Path(room_id): Path +) -> Result>, AppError> { + + check_user_in_room(&state, &token.subject, &room_id).await?; + let users = RoomService::get_users_in_room(state, room_id).await?; + Ok(Json(users)) +} + +pub async fn handle_get_joined_rooms( + State(state): State>, + Extension(token): Extension> +) -> Result>, AppError> { + + let rooms = RoomService::get_joined_rooms(state, token.subject).await?; + Ok(Json(rooms)) +} + +pub async fn handle_get_room_with_details( + State(state): State>, + Extension(token): Extension>, + Path(room_id): Path +) -> Result, AppError> { + + let room = RoomService::get_room_with_details(state, token.subject, room_id).await?; + Ok(Json(room)) +} + +pub async fn mark_room_as_read( + State(state): State>, + Extension(token): Extension>, + Path(room_id): Path +) -> Result<(), AppError> { + RoomService::mark_room_as_read(state, token.subject, room_id).await?; + Ok(()) +} + +pub async fn handle_create_room( + State(state): State>, + Extension(token): Extension>, + Json(payload): Json +) -> Result, AppError> { + + if !payload.invited_users.contains(&token.subject) { + return Err(AppError::ValidationError("Sender ID is not in the list of invited users.".to_string())); + } + + match payload.room_type { + RoomType::Single => { + if payload.invited_users.len() != 2 { + return Err(AppError::ValidationError("Personal rooms must have exactly two IDs (sender + one other).".to_string())); + } + } + RoomType::Group => { + if payload.invited_users.len() < 2 { + return Err(AppError::ValidationError("Groups must have more than one user.".to_string())); + } + } + } + let room = RoomService::create_room(state, token.subject, payload).await?; + Ok(Json(room)) +} + +pub async fn handle_get_room_list_item_by_id( + Extension(token): Extension>, + State(state): State>, + Path(room_id): Path +) -> Result, AppError> { + let room = RoomService::get_room_list_item_by_id(state, token.subject, room_id).await?; + Ok(Json(room)) +} + +pub async fn handle_leave_room( + Extension(token): Extension>, + State(state): State>, + Path(room_id): Path +) -> Result<(), AppError> { + RoomService::leave_room(state, token.subject, room_id).await?; + Ok(()) +} + +pub async fn handle_invite_to_room( + Extension(token): Extension>, + State(state): State>, + Path((room_id, user_id)): Path<(Uuid, Uuid)> +) -> Result<(), AppError> { + RoomService::invite_to_room(state, token.subject, room_id, user_id).await?; + Ok(()) +} + + + +pub async fn handle_search_existing_single_room( + Extension(token): Extension>, + State(state): State>, + Query(params): Query, +) -> Result>, AppError> { + let result = RoomService::find_existing_single_room(state, token.subject, params.with_user).await?; + Ok(Json(result)) +} + +pub async fn handle_save_room_image( + Extension(token): Extension>, + State(state): State>, + Path(room_id): Path, + mut multipart: Multipart +) -> Result, AppError> { + check_user_in_room(&state, &token.subject, &room_id).await?; + let mut image_data: Option = None; + loop { + match multipart.next_field().await { + Ok(Some(field)) => { + if field.name() == Some("image") { + let data = match field.bytes().await { + Ok(data) => data, + Err(_) => { + return Err(AppError::ValidationError("Error reading the image byte stream.".to_string())) + } + }; + image_data = Some(data); + break; + } + }, + Ok(None) => { + break; //stream finished + } + Err(err) => { //read error + error!("Bad image upload: {}", err.to_string()); + return Err(AppError::ValidationError("Error reading the image byte stream.".to_string())) + } + } + } + + if let Some(image_data) = image_data { + let response = RoomService::set_room_image(state, room_id, image_data).await?; + Ok(Json(response)) + } else { + Err(AppError::ValidationError("Required field 'image' not found in the upload.".to_string())) + } +} \ No newline at end of file diff --git a/src/rooms/mod.rs b/src/rooms/mod.rs index e13ff31..537b058 100644 --- a/src/rooms/mod.rs +++ b/src/rooms/mod.rs @@ -1,3 +1,4 @@ pub mod routes; -mod rooms; -pub mod timeline; \ No newline at end of file +mod timeline_service; +mod handler; +mod room_service; \ No newline at end of file diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs new file mode 100644 index 0000000..877a548 --- /dev/null +++ b/src/rooms/room_service.rs @@ -0,0 +1,286 @@ +use std::sync::Arc; +use bytes::Bytes; +use chrono::Utc; +use log::{error}; +use uuid::Uuid; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent}; +use crate::core::AppState; +use crate::errors::{AppError}; +use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; +use crate::model::{ChatRoom, ChatRoomEntity, ChatRoomWithUserDTO, MembershipStatus, NewRoom, RoomMember, RoomType, UploadResponse}; +use crate::utils::crop_image_from_center; + +pub struct RoomService; + +impl RoomService { + + pub async fn get_users_in_room(state: Arc, room_id: Uuid, ) -> Result, AppError> { + let users = state.room_repository.select_all_user_in_room(&room_id).await.map_err(|_| AppError::NotFound("Room not found:".to_string()))?; + Ok(users) + } + + pub async fn get_joined_rooms(state: Arc, client_id: Uuid, ) -> Result, AppError> { + let rooms = state.room_repository.get_joined_rooms(&client_id).await?; + Ok(rooms) + } + + pub async fn get_room_with_details(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { + + let (chat_room, users) = tokio::try_join!( //executing 2 queries async + state.room_repository.find_specific_joined_room(&room_id, &client_id), + state.room_repository.select_all_user_in_room(&room_id) + )?; + + match chat_room { + Some(room) => { + let room_details = ChatRoomWithUserDTO { + id: room.id, + room_type: room.room_type, + room_name: room.room_name.unwrap_or(String::from("Unnamed Chat")), + room_image_url: room.room_image_url, + created_at: room.created_at, + latest_message: room.latest_message, + unread: room.unread, + latest_message_preview_text: room.latest_message_preview_text, + users: users, + }; + Ok(room_details) + }, + None => Err(AppError::NotFound("Room not found:".to_string())) + } + } + + pub async fn mark_room_as_read(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { + let pl = state.room_repository.get_connection(); + state.room_repository.update_user_read_status(pl, &room_id, &client_id).await?; + Ok(()) + } + + pub async fn create_room(state: Arc, client_id: Uuid, new_room: NewRoom) -> Result { + let room_entity = state.room_repository.insert_room(new_room.clone()).await?; + let users = new_room.invited_users; + + if room_entity.room_type == RoomType::Single { + let other_user = match users.iter().find(|&&entry| entry != client_id) { + Some(other_user) => other_user, + None => return Err(AppError::ValidationError("Can't find other user.".to_string())) + }; + + //sending 2 specific room views to the users, because private rooms are shown like another user + let (room_client, room_receiver) = tokio::try_join!( //executing 2 queries async + state.room_repository.find_specific_joined_room(&room_entity.id, &client_id), + state.room_repository.find_specific_joined_room(&room_entity.id, other_user) + )?; + + if let (Some(creator_dto), Some(participator_dto)) = (room_client, room_receiver) { + + let broadcast = BroadcastChannel::get(); + + broadcast.send_event(Notification { + body: crate::broadcast::NotificationEvent::NewRoom {room: participator_dto}, + created_at: Utc::now() + }, other_user).await; + + broadcast.send_event(Notification { + body: crate::broadcast::NotificationEvent::NewRoom {room: creator_dto.clone()}, + created_at: Utc::now() + }, &client_id).await; + + Ok(creator_dto) + } else { + Err(AppError::ProcessingError("Newly created room is null.".to_string())) + } + } else { //is group room + + let room = state.room_repository.find_specific_joined_room(&room_entity.id, &client_id).await?.ok_or_else(|| { + AppError::ProcessingError("Newly created room is null.".to_string()) + })?; + + BroadcastChannel::get().send_event_to_all( + users, + Notification { + body: crate::broadcast::NotificationEvent::NewRoom {room: room.clone()}, + created_at: Utc::now() + } + ).await; + Ok(room) + } + } + + pub async fn get_room_list_item_by_id(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { + let room = state.room_repository.find_specific_joined_room(&room_id, &client_id).await?.ok_or_else(|| { + AppError::NotFound("Room not found.".to_string()) + })?; + Ok(room) + } + + pub async fn leave_room(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { + let (room, users) = tokio::try_join!( //executing 2 queries async + state.room_repository.select_room(&room_id), + state.room_repository.select_joined_user_in_room(&room_id) + )?; + let leaving_user = match users.iter().find(|user| user.id == client_id) { + Some(user) => user.clone(), + None => { + return Err(AppError::Blocked("Client is not in this room.".to_string())) + } + }; + if room.room_type == RoomType::Single { //if someone leaves a single room, the whole room is getting wiped! + handle_leave_private_room(state, room, users).await?; + Ok(()) + } else { //handle the group leave logic + handle_leave_group_room(state, room, users, leaving_user).await?; + Ok(()) + } + } + + pub async fn invite_to_room(state: Arc, client_id: Uuid, room_id: Uuid, user_id: Uuid) -> Result<(), AppError> { + let (room, users) = tokio::try_join!( //executing 2 queries async + state.room_repository.select_room(&room_id), + state.room_repository.select_joined_user_in_room(&room_id) + )?; + if room.room_type == RoomType::Single { + return Err(AppError::ValidationError("Private rooms doesn't allow invites!.".to_string())) + }; + //we have to check if the inviter is in the room and the invited user isn't! + let user_to_find = users.iter().find(|user| user.id == client_id); + let user_to_exclude = users.iter().find(|user| user.id == user_id); + match (user_to_find, user_to_exclude) { + (Some(_inviter), None) => {} //we have checked the invite rules and continue + _ => { + return Err(AppError::ValidationError("User conditions not met in this room.".to_string())) + } + }; + //add him to the room + let user = state.room_repository.add_user_to_room(&user_id, &room_id).await?; + + //build room change message + let message = Message::new(room_id, user.id, MessageBody::RoomChange(RoomChangeBody::UserJoined {related_user: user.clone()})) + .map_err(|_| AppError::ProcessingError("Unable to create room message".to_string()))?; + + //sending room change event to all previous users in the room + let send_to: Vec = users.iter().map(|user| user.id).collect(); + save_message_and_broadcast(message, &state, send_to).await?; + + //sending new room event to invited user + let room_for_user = state.room_repository.find_specific_joined_room(&room_id, &user_id).await?.ok_or_else(|| { + AppError::ProcessingError("Unable to find room for the invited user.".to_string()) + })?; + BroadcastChannel::get().send_event( + Notification { + body: crate::broadcast::NotificationEvent::NewRoom {room: room_for_user}, + created_at: Utc::now() + }, + &user.id + ).await; + + Ok(()) + } + + pub async fn find_existing_single_room(state: Arc, client_id: Uuid, with_user: Uuid) -> Result, AppError> { + let room_id = state.room_repository.find_room_between_users(&client_id, &with_user).await?; + Ok(room_id) + } + + pub async fn set_room_image(state: Arc, room_id: Uuid, image_data: Bytes) -> Result { + let img = match crop_image_from_center(&image_data, 500, 500) { + Ok(img) => img, + Err(_err) => { + error!("Unable to crop image: {}", _err.to_string()); + return Err(AppError::ProcessingError("Unable to crop image.".to_string())) + } + }; + let object_id = format!("rooms/{}", room_id); + if let Err(err) = state.s3_bucket.insert_object(&object_id, img).await { + error!("{}", err.to_string()); + return Err(AppError::S3Error("Unable save image in s3 bucket.".to_string())) + }; + state.room_repository.update_room_img_url(&room_id, &object_id).await?; + let response = UploadResponse { + image_url: object_id.clone(), + image_name: format!("{}.png", object_id), + }; + Ok(response) + } + +} + +async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { + state.message_repository.clear_chat_room_messages(&room.id).await?; + + let mut tx = state.room_repository.start_transaction().await?; + state.room_repository.delete_room(&mut *tx, &room.id).await?; + tx.commit().await?; + + let send_to: Vec = users.iter().map(|user| user.id).collect(); + BroadcastChannel::get().send_event_to_all( + send_to, + Notification { + body: LeaveRoom {room_id: room.id}, + created_at: Utc::now() + } + ).await; + Ok(()) +} + +async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: RoomMember) -> Result<(), AppError> { + let mut tx = state.room_repository.start_transaction().await?; + state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user).await?; + leaving_user.membership_status = MembershipStatus::Left; + + if users.len() == 1 { //last user, delete this room now + state.message_repository.clear_chat_room_messages(&room.id).await?; + state.room_repository.delete_room(&mut *tx, &room.id).await?; + tx.commit().await?; + + BroadcastChannel::get().send_event( + Notification { + body: LeaveRoom {room_id: room.id}, + created_at: Utc::now() + }, + &leaving_user.id + ).await; + + //delete room image if it exists: + if let Some(url) = room.room_image_url { + state.s3_bucket.delete_object(&url).await + .map_err(|_| AppError::ProcessingError("Unable to delete image from room".to_string()))?; + } + + Ok(()) + } else { //find and handle the leaving user + let message = Message::new(room.id, leaving_user.id, MessageBody::RoomChange(RoomChangeBody::UserLeft {related_user: leaving_user.clone()})) + .map_err(|_err| AppError::ProcessingError("Unable to create room message".to_string()))?; + + let send_to: Vec = users.iter().map(|user| user.id).collect(); + save_message_and_broadcast(message, &state, send_to).await?; + tx.commit().await?; + + BroadcastChannel::get().send_event( + Notification { + body: LeaveRoom {room_id: room.id}, + created_at: Utc::now() + }, + &leaving_user.id + ).await; + + Ok(()) + } +} + +async fn save_message_and_broadcast(message: Message, state: &Arc, to_users: Vec) -> Result<(), AppError> { + state.message_repository.insert_data(message.clone()).await?; + + let mapped_msg = message.to_dto().map_err(|_| { + AppError::ProcessingError("Unable to cast message to dto.".to_string()) + })?; + + let notification = Notification { + body: RoomChangeEvent{message: mapped_msg}, + created_at: Utc::now() + }; + + BroadcastChannel::get().send_event_to_all(to_users, notification).await; + Ok(()) +} \ No newline at end of file diff --git a/src/rooms/rooms.rs b/src/rooms/rooms.rs deleted file mode 100644 index 1be9eb9..0000000 --- a/src/rooms/rooms.rs +++ /dev/null @@ -1,484 +0,0 @@ -use std::sync::Arc; -use axum::{Extension, Json}; -use axum::extract::{Path, State, Multipart, Query}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use chrono::{Utc}; -use log::{error, info}; -use uuid::Uuid; -use bytes::Bytes; -use crate::errors::{ErrorCode, HttpError}; -use crate::keycloak::decode::KeycloakToken; -use crate::model::{ChatRoomWithUserDTO, MembershipStatus, NewRoom as UploadRoom, RoomType, ChatRoomEntity, RoomMember, UploadResponse, SingleRoomSearchUserParams}; -use crate::utils::{check_user_in_room, crop_image_from_center}; -use crate::broadcast::{BroadcastChannel, Notification}; -use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; -use crate::core::AppState; -use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; - - -pub async fn get_users_in_room( - State(state): State>, - Path(room_id): Path -) -> impl IntoResponse { - match state.room_repository.select_all_user_in_room(&room_id).await { - Ok(users) => Json(users).into_response(), - Err(err) => HttpError::bad_request(ErrorCode::RoomNotFound, err.to_string()).into_response() - } -} - -pub async fn get_joined_rooms( - State(state): State>, - Extension(token): Extension>, -) -> impl IntoResponse { - match state.room_repository.get_joined_rooms(&token.subject).await { - Ok(rooms) => Json(rooms).into_response(), - Err(err) => HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::UnexpectedError, err.to_string()).into_response() - } -} - -pub async fn get_room_with_details( - State(state): State>, - Extension(token): Extension>, - Path(room_id): Path -) -> impl IntoResponse { - if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { - return err.into_response(); - } - - let res = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_id, &token.subject), - state.room_repository.select_all_user_in_room(&room_id) - ); - - match res { - Ok((room_option, users)) => { - let chat_room = match room_option { - Some(room) => room, - None => return HttpError::new(StatusCode::NOT_FOUND, ErrorCode::RoomNotFound, "Room not found").into_response() - }; - - let room_details = ChatRoomWithUserDTO { - id: chat_room.id, - room_type: chat_room.room_type, - room_name: chat_room.room_name.unwrap_or(String::from("Unnamed Chat")), - room_image_url: chat_room.room_image_url, - created_at: chat_room.created_at, - latest_message: chat_room.latest_message, - unread: chat_room.unread, - latest_message_preview_text: chat_room.latest_message_preview_text, - users: users, - }; - Json(room_details).into_response() - } - Err(err) => { - HttpError::bad_request(ErrorCode::RoomNotFound, err.to_string()).into_response() - } - } - -} - -pub async fn mark_room_as_read( - State(state): State>, - Extension(token): Extension>, - Path(room_id): Path -) -> impl IntoResponse { - let pl = state.room_repository.get_connection(); - match state.room_repository.update_user_read_status(pl, &room_id, &token.subject).await { - Ok(()) => StatusCode::OK.into_response(), - Err(_) => HttpError::bad_request(ErrorCode::UnexpectedError,"Can't update user read status.").into_response() - } -} - - -pub async fn create_room( - Extension(token): Extension>, - State(state): State>, - Json(payload): Json -) -> impl IntoResponse { - - if !payload.invited_users.contains(&token.subject) { - return HttpError::bad_request(ErrorCode::InvalidContent, "Sender ID is not in the list of invited users.").into_response(); - } - - match payload.room_type { - RoomType::Single => { - if payload.invited_users.len() != 2 { - return HttpError::bad_request(ErrorCode::InvalidContent, "Personal rooms must have exactly two IDs (sender + one other).").into_response(); - } - } - RoomType::Group => { - if payload.invited_users.len() < 2 { - return HttpError::bad_request(ErrorCode::InvalidContent, "Groups must have more than one user.").into_response(); - } - } - } - - let room_entity = match state.room_repository.insert_room(payload.clone()).await { - Ok(room) => room, - Err(error) => { - error!("{}", error); - return HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::UnexpectedError, "Unable to persist the room.").into_response() - } - }; - - let users = payload.invited_users; - - if room_entity.room_type == RoomType::Single { - let other_user = match users.iter().find(|&&entry| entry != token.subject) { - Some(other_user) => other_user, - None => return HttpError::bad_request(ErrorCode::InvalidContent,"Can't find other user.").into_response(), - }; - - //sending 2 specific room views to the users, because private rooms are shown like another user - let result = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_entity.id, &token.subject), - state.room_repository.find_specific_joined_room(&room_entity.id, other_user) - ); - match result { - Ok((room_creator, room_participator)) => { - if let (Some(creator_dto), Some(participator_dto)) = (room_creator, room_participator) { - let broadcast = BroadcastChannel::get(); - - broadcast.send_event(Notification { - body: NewRoom {room: participator_dto}, - created_at: Utc::now() - }, other_user).await; - - broadcast.send_event(Notification { - body: NewRoom {room: creator_dto.clone()}, - created_at: Utc::now() - }, &token.subject).await; - - Json(creator_dto).into_response() - } else { - HttpError::bad_request(ErrorCode::UnexpectedError,"Room for participator is null.").into_response() - } - } - Err(error) => { - error!("{}", error); - HttpError::bad_request(ErrorCode::UnexpectedError,"Can't find the room.").into_response() - } - } - - } else { //is group room - - let room = match state.room_repository.find_specific_joined_room(&room_entity.id, &token.subject).await { - Ok(Some(room)) => room, - Ok(None) => return HttpError::bad_request(ErrorCode::UnexpectedError,"Room not found after creation.").into_response(), - Err(error) => { - error!("{}", error); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Room not found after creation.").into_response() - } - }; - - BroadcastChannel::get().send_event_to_all( - users, - Notification { - body: NewRoom{room: room.clone()}, - created_at: Utc::now() - } - ).await; - Json(room).into_response() - } -} - - -pub async fn get_room_list_item_by_id( - Extension(token): Extension>, - State(state): State>, - Path(room_id): Path -) -> impl IntoResponse { - match state.room_repository.find_specific_joined_room(&room_id, &token.subject).await { - Ok(Some(room)) => Json(room).into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(err) => HttpError::bad_request(ErrorCode::UnexpectedError, err.to_string()).into_response() - } -} - - -pub async fn leave_room( - Extension(token): Extension>, - State(state): State>, - Path(room_id): Path -) -> impl IntoResponse { - let result = tokio::try_join!( //executing 2 queries async - state.room_repository.select_room(&room_id), - state.room_repository.select_joined_user_in_room(&room_id) - ); - let (room, users) = match result { - Ok((room, users)) => (room, users), - Err(error) => { - error!("{}", error.to_string()); - return HttpError::bad_request(ErrorCode::InvalidContent,"Can't get room & user state.").into_response() - } - }; - let leaving_user = match users.iter().find(|user| user.id == token.subject) { - Some(user) => {user.clone()} - None => { - return HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions,"User not found in this room.").into_response(); - } - }; - if room.room_type == RoomType::Single { //if someone leaves a single room, the whole room is getting wiped! - handle_leave_private_room(state, room, users).await - } else { //handle the group leave logic - handle_leave_group_room(state, room, users, leaving_user).await - } -} - -async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Response { - if let Err(err) = state.message_repository.clear_chat_room_messages(&room.id).await { - error!("Can't clear chat messages for this room: {}", err); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to delete this room.").into_response(); - }; - let mut tx = state.room_repository.start_transaction().await.unwrap(); - if let Err(err) = state.room_repository.delete_room(&mut *tx, &room.id).await { - error!("Can't delete room: {}", err); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to change room membership state in db.").into_response(); - }; - let send_to: Vec = users.iter().map(|user| user.id).collect(); - BroadcastChannel::get().send_event_to_all( - send_to, - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - } - ).await; - tx.commit().await.unwrap(); - StatusCode::OK.into_response() -} - -async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: RoomMember) -> Response { - let mut tx = state.room_repository.start_transaction().await.unwrap(); - if let Err(err) = state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user).await { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to change room membership state in db.").into_response(); - } - leaving_user.membership_status = MembershipStatus::Left; - - if users.len() == 1 { //last user, delete this room now - if let Err(err) = state.message_repository.clear_chat_room_messages(&room.id).await { - error!("Can't clear chat messages for this room: {}", err); - }; - if let Err(err) = state.room_repository.delete_room(&mut *tx, &room.id).await { - error!("Can't delete room: {}", err); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to change room membership state in db.").into_response(); - }; - BroadcastChannel::get().send_event( - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - }, - &leaving_user.id - ).await; - tx.commit().await.unwrap(); - - //delete room image if it exists: - if room.room_image_url.is_some() { - let url = room.room_image_url.unwrap(); - match state.s3_bucket.delete_object(&url).await { - Ok(_) => { - info!("Deleted image for room: {}", &room.id); - }, - Err(err) => { - error!("Can't delete image of room: {}", err); - } - }; - } - - StatusCode::OK.into_response() - } else { //find and handle the leaving user - let message = match Message::new(room.id, leaving_user.id, MessageBody::RoomChange(RoomChangeBody::UserLeft {related_user: leaving_user.clone()})) { - Ok(json) => json, - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Can't serialize message").into_response() - } - }; - - let send_to: Vec = users.iter().map(|user| user.id).collect(); - save_message_and_broadcast(message, &state, send_to).await; - BroadcastChannel::get().send_event( - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - }, - &leaving_user.id - ).await; - tx.commit().await.unwrap(); - StatusCode::OK.into_response() - } -} - - -pub async fn invite_to_room( - Extension(token): Extension>, - State(state): State>, - Path((room_id, user_id)): Path<(Uuid, Uuid)> -) -> impl IntoResponse { - - let result = tokio::try_join!( //executing 2 queries async - state.room_repository.select_room(&room_id), - state.room_repository.select_joined_user_in_room(&room_id) - ); - let (room, users) = match result { - Ok((room, users)) => (room, users), - Err(error) => { - error!("{}", error.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError, "Can't get room & user state.").into_response() - } - }; - if room.room_type == RoomType::Single { - return HttpError::bad_request(ErrorCode::InvalidContent, "Room type single doesn't allow invites!").into_response(); - } - //we have to check if the inviter is in the room and the invited user isn't! - let user_to_find = users.iter().find(|user| user.id == token.subject); - let user_to_exclude = users.iter().find(|user| user.id == user_id); - match (user_to_find, user_to_exclude) { - (Some(_inviter), None) => {} //we have checked the invite rules and continue - _ => { - return HttpError::bad_request(ErrorCode::InvalidContent,"User conditions not met in this room.").into_response(); - } - }; - - //add him to the room - let user = match state.room_repository.add_user_to_room(&user_id, &room_id).await { - Ok(user) => user, - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Unable to change room membership state in db.").into_response(); - } - }; - - //build room change message - let message = match Message::new(room_id, user.id, MessageBody::RoomChange(RoomChangeBody::UserJoined {related_user: user.clone()})) { - Ok(json) => json, - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't serialize message").into_response() - } - }; - //sending room change event to all previous users in the room - let send_to: Vec = users.iter().map(|user| user.id).collect(); - save_message_and_broadcast(message, &state, send_to).await; - - - //sending new room event to invited user - let room_for_user = match state.room_repository.find_specific_joined_room(&room_id, &user_id).await { - Ok(Some(room)) => room, - Ok(None) => return HttpError::bad_request(ErrorCode::UnexpectedError,"Room not found after creation.").into_response(), - Err(err) => { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Room not found after creation.").into_response() - } - }; - - //notify the invited user: - BroadcastChannel::get().send_event( - Notification { - body: NewRoom{room: room_for_user}, - created_at: Utc::now() - }, - &user.id - ).await; - StatusCode::OK.into_response() -} - -async fn save_message_and_broadcast(message: Message, state: &Arc, to_users: Vec) -> Response { - if let Err(err) = state.message_repository.insert_data(message.clone()).await { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Unable to persist the message.").into_response(); - }; - - let mapped_msg = match message.to_dto() { - Ok(msg) => msg, - Err(err) => { - return HttpError::bad_request(ErrorCode::UnexpectedError,format!("Can't serialize message: {}", err)).into_response() - } - }; - let note = Notification { - body: RoomChangeEvent{message: mapped_msg}, - created_at: Utc::now() - }; - BroadcastChannel::get().send_event_to_all(to_users, note).await; - StatusCode::OK.into_response() -} - - -pub async fn search_existing_single_room( - Extension(token): Extension>, - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - match state.room_repository.find_room_between_users(&token.subject, ¶ms.with_user).await { - Ok(Some(room)) => (StatusCode::OK, room.to_string()).into_response(), - Ok(None) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - error!("{}", e.to_string()); - HttpError::bad_request(ErrorCode::UnexpectedError,"Unexpected data query error.").into_response() - } - } -} - -pub async fn save_room_image( - Extension(token): Extension>, - State(state): State>, - Path(room_id): Path, - mut multipart: Multipart -) -> impl IntoResponse { - if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { - return err.into_response(); - } - - let mut image_data: Option = None; - - loop { - match multipart.next_field().await { - Ok(Some(field)) => { - if field.name() == Some("image") { - let data = match field.bytes().await { - Ok(data) => data, - Err(_) => { - return HttpError::bad_request(ErrorCode::UnexpectedError,"Error reading the image byte stream.").into_response() - } - }; - image_data = Some(data); - break; - } - }, - Ok(None) => { - break; //stream finished - } - Err(err) => { //read error - error!("Bad image upload: {}", err.to_string()); - return HttpError::bad_request(ErrorCode::InvalidContent,"Can't extract image file.").into_response() - } - } - } - - if let Some(image_data) = image_data { - let img = match crop_image_from_center(&image_data, 500, 500) { - Ok(img) => img, - Err(err) => { - return err.into_response() - } - }; - let object_id = format!("rooms/{}", room_id); - if let Err(err) = state.s3_bucket.insert_object(&object_id, img).await { - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't save image.").into_response() - }; - if let Err(err) = state.room_repository.update_room_img_url(&room_id, &object_id).await{ - error!("{}", err.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't save image.").into_response() - }; - let response = UploadResponse { - image_url: object_id.clone(), - image_name: format!("{}.png", object_id), - }; - - (StatusCode::CREATED, Json(response)).into_response() - } else { - HttpError::bad_request(ErrorCode::InvalidContent,"Required field 'image' not found in the upload.").into_response() - } -} \ No newline at end of file diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs index 84f0da2..c31b572 100644 --- a/src/rooms/routes.rs +++ b/src/rooms/routes.rs @@ -2,20 +2,20 @@ use std::sync::Arc; use axum::Router; use axum::routing::{get, post}; use crate::core::AppState; -use crate::rooms::rooms::{create_room, get_joined_rooms, get_room_list_item_by_id, get_room_with_details, get_users_in_room, invite_to_room, leave_room, mark_room_as_read, save_room_image, search_existing_single_room}; -use crate::rooms::timeline::scroll_chat_timeline; +use crate::rooms::handler::{handle_create_room, handle_get_joined_rooms, handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, handle_search_existing_single_room, mark_room_as_read}; + pub fn create_room_routes() -> Router> { Router::new() - .route("/api/rooms/create-room", post(create_room)) - .route("/api/rooms/{room_id}/users", get(get_users_in_room)) - .route("/api/rooms/{room_id}/detailed", get(get_room_with_details)) - .route("/api/rooms/{room_id}/timeline", get(scroll_chat_timeline)) + .route("/api/rooms/create-room", post(handle_create_room)) + .route("/api/rooms/{room_id}/users", get(handle_get_users_in_room)) + .route("/api/rooms/{room_id}/detailed", get(handle_get_room_with_details)) + .route("/api/rooms/{room_id}/timeline", get(handle_scroll_chat_timeline)) .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) - .route("/api/rooms/{room_id}", get(get_room_list_item_by_id)) - .route("/api/rooms/{room_id}/leave", post(leave_room)) - .route("/api/rooms/search", get(search_existing_single_room)) - .route("/api/rooms/{room_id}/invite/{user_id}", post(invite_to_room)) - .route("/api/rooms/{room_id}/upload-img", post(save_room_image)) - .route("/api/rooms", get(get_joined_rooms)) + .route("/api/rooms/{room_id}", get(handle_get_room_list_item_by_id)) + .route("/api/rooms/{room_id}/leave", post(handle_leave_room)) + .route("/api/rooms/search", get(handle_search_existing_single_room)) + .route("/api/rooms/{room_id}/invite/{user_id}", post(handle_invite_to_room)) + .route("/api/rooms/{room_id}/upload-img", post(handle_save_room_image)) + .route("/api/rooms", get(handle_get_joined_rooms)) } diff --git a/src/rooms/timeline.rs b/src/rooms/timeline.rs deleted file mode 100644 index b5161b8..0000000 --- a/src/rooms/timeline.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::sync::Arc; -use axum::{Extension, Json}; -use axum::extract::{Path, Query, State}; -use axum::response::IntoResponse; -use chrono::{DateTime, Utc}; -use log::{error}; -use serde::Deserialize; -use uuid::Uuid; -use crate::errors::{ErrorCode, HttpError}; -use crate::utils::{check_user_in_room}; -use crate::core::AppState; -use crate::keycloak::decode::KeycloakToken; -use crate::messaging::model::MessageDTO; - - -#[derive(Deserialize)] -pub struct TimelineQuery { - timestamp: DateTime -} - -pub async fn scroll_chat_timeline( - Extension(token): Extension>, - State(state): State>, - Path(room_id): Path, - Query(params): Query -) -> impl IntoResponse { - - if let Err(err) = check_user_in_room(&state, &token.subject, &room_id).await { - return err.into_response(); - } - match state.message_repository.fetch_data(params.timestamp, room_id).await { - Ok(data) => { - let mut mapped: Vec = vec![]; - data.into_iter().for_each(|message| { - match message.to_dto() { - Ok(dto) => mapped.push(dto), - Err(err) => { - error!("Failed to convert message to DTO: {}", err); - } - } - }); - Json(mapped).into_response() - }, - Err(err) => { - error!("{}", err.to_string()); - HttpError::bad_request(ErrorCode::UnexpectedError, "Unable to fetch message data.").into_response() - } - } -} \ No newline at end of file diff --git a/src/rooms/timeline_service.rs b/src/rooms/timeline_service.rs new file mode 100644 index 0000000..661dd87 --- /dev/null +++ b/src/rooms/timeline_service.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; +use chrono::{DateTime, Utc}; +use log::error; +use uuid::Uuid; +use crate::core::AppState; +use crate::errors::AppError; +use crate::messaging::model::MessageDTO; + +pub struct TimelineService; + +impl TimelineService { + + pub async fn scroll_chat_timeline( + state: Arc, + room_id: Uuid, + timestamp: DateTime + ) -> Result, AppError> { + + let data = state.message_repository.fetch_data(timestamp, room_id).await + .map_err(|err| AppError::DatabaseError(err))?; + + let mut mapped: Vec = vec![]; + data.into_iter().for_each(|message| { + match message.to_dto() { + Ok(dto) => mapped.push(dto), + Err(err) => { + error!("Failed to convert message to DTO: {}", err); + } + } + }); + Ok(mapped) + } +} \ No newline at end of file diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs index 0cced84..9c42093 100644 --- a/src/user_relationship/user_service.rs +++ b/src/user_relationship/user_service.rs @@ -73,7 +73,7 @@ impl UserService { let user = db_user.ok_or_else(|| { AppError::NotFound(format!("User with ID {} not found.", user_id)) })?; - + Ok(user.to_dto(current_user_id)) } @@ -335,7 +335,7 @@ impl UserService { RelationshipState::A_BLOCKED, ).await?; }, - + (RelationshipState::A_BLOCKED, true) | (RelationshipState::B_BLOCKED, false) => { // Fall 2: only client blocked, remove relationship state.user_repository.delete_relationship_state( &mut tx, @@ -353,7 +353,7 @@ impl UserService { )); } } - + Ok(()) } diff --git a/src/utils.rs b/src/utils.rs index 1b44cb9..ec96091 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,10 +2,8 @@ use std::sync::Arc; use bytes::Bytes; use uuid::Uuid; use std::io::Cursor; -use http::StatusCode; -use image::GenericImageView; -use log::error; -use crate::errors::{AppError, ErrorCode, HttpError}; +use image::{GenericImageView, ImageError}; +use crate::errors::{AppError}; use crate::core::AppState; @@ -26,14 +24,11 @@ pub fn crop_image_from_center( data: &Bytes, target_width: u32, target_height: u32, -) -> Result { +) -> Result { let img = match image::load_from_memory(data) { Ok(img) => img, - Err(err) => { - error!("{}", err); - return Err(HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::FileProcessingError, "Unable to load the image.")) - } + Err(err) => return Err(err) }; let (original_width, original_height) = img.dimensions(); @@ -51,10 +46,7 @@ pub fn crop_image_from_center( Ok(_) => { Ok(Bytes::from(buffer.into_inner())) }, - Err(err) => { - error!("{}", err); - Err(HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::FileProcessingError, "Image processing failed.")) - } + Err(err) => Err(err) } } From faa19b3d7ec7b393c1d7d4e83f4a99f2ceab0f9f Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 4 Nov 2025 23:55:41 +0100 Subject: [PATCH 08/23] Introduce blocked user validation and improve room creation flow --- .idea/workspace.xml | 120 +++++++++--------- ...bcb82a0c51f864ee0dfbe47c7cc141f336f5a.json | 17 +++ ...96623c4c86c3769fc349d59dab3b498aeeed3.json | 52 ++++++++ ...da0cc12d275c79850b6349bb783c661003132.json | 53 ++++++++ ...4a4053b476890e251cd685e6d82f2cee83e51.json | 24 ++++ ...ff6270e06aceaa4db5e38bf0919f186cda1dd.json | 14 ++ ...b1fe58536cc4ab24538807d8b94a7da31bf0b.json | 15 +++ ...7947ee2b621391f985e00304f7b04ea42ade3.json | 14 ++ ...61d64b7f3b697f5d59b42cf65471de70fa100.json | 16 +++ ...fc93e3b19d3fc5430db90c97086bbd0ad4fe1.json | 41 ++++++ ...bee11f5821b93c96aa5c89c278476eb865b56.json | 52 ++++++++ src/model/mod.rs | 3 - src/model/queries.rs | 8 -- src/repository/user_repository.rs | 20 +++ src/rooms/handler.rs | 27 +++- src/rooms/mod.rs | 2 +- src/rooms/room_service.rs | 4 +- src/user_relationship/handler.rs | 14 +- src/user_relationship/mod.rs | 2 +- src/user_relationship/user_service.rs | 9 ++ 20 files changed, 427 insertions(+), 80 deletions(-) create mode 100644 .sqlx/query-08a8720e1a0ddacd02b9ff70590bcb82a0c51f864ee0dfbe47c7cc141f336f5a.json create mode 100644 .sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json create mode 100644 .sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json create mode 100644 .sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json create mode 100644 .sqlx/query-880728c09ffbad8a14258d0437dff6270e06aceaa4db5e38bf0919f186cda1dd.json create mode 100644 .sqlx/query-8cf5a54604a8adbd60f6a442656b1fe58536cc4ab24538807d8b94a7da31bf0b.json create mode 100644 .sqlx/query-ac6f8ebbf3df687dc4b37fb320a7947ee2b621391f985e00304f7b04ea42ade3.json create mode 100644 .sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json create mode 100644 .sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json create mode 100644 .sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json delete mode 100644 src/model/queries.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 5ca1b39..bd18ae9 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,19 +12,17 @@ - - - - + - - + + + + - - - + + + - - { - "keyToString": { - "Cargo.Run ISM.executor": "Run", - "Cargo.Run.executor": "Run", - "Cargo.sqlx.executor": "Run", - "Docker.Dockerfile runtime.executor": "Run", - "Docker.Dockerfile.executor": "Run", - "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", - "Docker.compose.yaml.console: Compose Deployment.executor": "Run", - "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", - "Docker.compose.yaml: Compose Deployment.executor": "Run", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", - "RunOnceActivity.git.unshallow": "true", - "RunOnceActivity.rust.reset.selective.auto.import": "true", - "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true", - "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", - "git-widget-placeholder": "master", - "ignore.virus.scanning.warn.message": "true", - "junie.onboarding.icon.badge.shown": "true", - "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", - "node.js.detected.package.eslint": "true", - "node.js.detected.package.tslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", - "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", - "org.rust.first.attach.projects": "true", - "settings.editor.selected.configurable": "language.rust.cargo.check", - "to.speed.mode.migration.done": "true", - "vue.rearranger.settings.migration": "true" + +}]]> @@ -129,7 +127,7 @@ - + @@ -397,15 +395,7 @@ - - - - - @@ -812,7 +810,6 @@ - @@ -837,7 +834,8 @@ - diff --git a/.sqlx/query-08a8720e1a0ddacd02b9ff70590bcb82a0c51f864ee0dfbe47c7cc141f336f5a.json b/.sqlx/query-08a8720e1a0ddacd02b9ff70590bcb82a0c51f864ee0dfbe47c7cc141f336f5a.json new file mode 100644 index 0000000..2bcc140 --- /dev/null +++ b/.sqlx/query-08a8720e1a0ddacd02b9ff70590bcb82a0c51f864ee0dfbe47c7cc141f336f5a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_relationship (user_a_id, user_b_id, state, relationship_change_timestamp)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "08a8720e1a0ddacd02b9ff70590bcb82a0c51f864ee0dfbe47c7cc141f336f5a" +} diff --git a/.sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json b/.sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json new file mode 100644 index 0000000..908efce --- /dev/null +++ b/.sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count\n FROM app_user u\n INNER JOIN user_relationship ur ON\n (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR\n (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "street_credits", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "friends_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false + ] + }, + "hash": "2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3" +} diff --git a/.sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json b/.sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json new file mode 100644 index 0000000..4e870b3 --- /dev/null +++ b/.sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count\n FROM\n app_user u\n INNER JOIN\n user_relationship rl ON u.id = (\n CASE\n WHEN rl.user_a_id = $1 THEN rl.user_b_id\n WHEN rl.user_b_id = $1 THEN rl.user_a_id\n ELSE NULL\n END\n )\n WHERE\n rl.state = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "street_credits", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "friends_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false + ] + }, + "hash": "3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132" +} diff --git a/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json b/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json new file mode 100644 index 0000000..b5d69c1 --- /dev/null +++ b/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_b_id FROM user_relationship\n WHERE user_a_id = $1 AND user_b_id = ANY($2) AND state = ANY($3)\n UNION\n SELECT user_a_id FROM user_relationship\n WHERE user_b_id = $1 AND user_a_id = ANY($2) AND state = ANY($3)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_b_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51" +} diff --git a/.sqlx/query-880728c09ffbad8a14258d0437dff6270e06aceaa4db5e38bf0919f186cda1dd.json b/.sqlx/query-880728c09ffbad8a14258d0437dff6270e06aceaa4db5e38bf0919f186cda1dd.json new file mode 100644 index 0000000..7ecaad4 --- /dev/null +++ b/.sqlx/query-880728c09ffbad8a14258d0437dff6270e06aceaa4db5e38bf0919f186cda1dd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE app_user\n SET friends_count = friends_count + 1\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "880728c09ffbad8a14258d0437dff6270e06aceaa4db5e38bf0919f186cda1dd" +} diff --git a/.sqlx/query-8cf5a54604a8adbd60f6a442656b1fe58536cc4ab24538807d8b94a7da31bf0b.json b/.sqlx/query-8cf5a54604a8adbd60f6a442656b1fe58536cc4ab24538807d8b94a7da31bf0b.json new file mode 100644 index 0000000..af3803e --- /dev/null +++ b/.sqlx/query-8cf5a54604a8adbd60f6a442656b1fe58536cc4ab24538807d8b94a7da31bf0b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_relationship\n WHERE user_a_id = $1 AND user_b_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8cf5a54604a8adbd60f6a442656b1fe58536cc4ab24538807d8b94a7da31bf0b" +} diff --git a/.sqlx/query-ac6f8ebbf3df687dc4b37fb320a7947ee2b621391f985e00304f7b04ea42ade3.json b/.sqlx/query-ac6f8ebbf3df687dc4b37fb320a7947ee2b621391f985e00304f7b04ea42ade3.json new file mode 100644 index 0000000..472c882 --- /dev/null +++ b/.sqlx/query-ac6f8ebbf3df687dc4b37fb320a7947ee2b621391f985e00304f7b04ea42ade3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE app_user\n SET friends_count = friends_count - 1\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ac6f8ebbf3df687dc4b37fb320a7947ee2b621391f985e00304f7b04ea42ade3" +} diff --git a/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json b/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json new file mode 100644 index 0000000..37765e5 --- /dev/null +++ b/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_relationship\n SET state = $1, relationship_change_timestamp = NOW()\n WHERE user_a_id = $2 AND user_b_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100" +} diff --git a/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json b/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json new file mode 100644 index 0000000..6733059 --- /dev/null +++ b/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ur.user_a_id,\n ur.user_b_id,\n ur.state as \"state: RelationshipState\",\n ur.relationship_change_timestamp\n FROM user_relationship ur\n WHERE ur.user_a_id = $1 AND ur.user_b_id = $2 OR ur.user_b_id = $1 AND ur.user_a_id = $2\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_a_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_b_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state: RelationshipState", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "relationship_change_timestamp", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1" +} diff --git a/.sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json b/.sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json new file mode 100644 index 0000000..02c3e84 --- /dev/null +++ b/.sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n r_user.id,\n r_user.display_name,\n r_user.profile_picture,\n r_user.street_credits,\n r_user.description,\n r_user.friends_count\n FROM app_user r_user\n WHERE r_user.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "street_credits", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "friends_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false + ] + }, + "hash": "bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56" +} diff --git a/src/model/mod.rs b/src/model/mod.rs index b0ff45b..deb6330 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,9 +2,6 @@ mod room; pub mod room_member; mod response_utils; -mod queries; - pub use room_member::*; pub use room::*; pub use response_utils::*; -pub use queries::*; diff --git a/src/model/queries.rs b/src/model/queries.rs deleted file mode 100644 index 08403b2..0000000 --- a/src/model/queries.rs +++ /dev/null @@ -1,8 +0,0 @@ -use serde::Deserialize; -use uuid::Uuid; - -#[derive(Deserialize, Debug)] -pub struct SingleRoomSearchUserParams { - #[serde(rename = "withUser")] - pub with_user: Uuid -} \ No newline at end of file diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index d2659b3..958a246 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -251,4 +251,24 @@ impl UserRepository { Ok(()) } + pub async fn find_blocked_relationships(&self, client_id: &Uuid, users_to_validate: &Vec) -> Result, Error> { + let blocked_states_str: [&str; 3] = ["A_BLOCKED", "B_BLOCKED", "ALL_BLOCKED"]; + let blocked_states_string_vec: Vec = blocked_states_str.map(String::from).to_vec(); + + let blocked_users_optional: Vec> = sqlx::query_scalar!( + r#" + SELECT user_b_id FROM user_relationship + WHERE user_a_id = $1 AND user_b_id = ANY($2) AND state = ANY($3) + UNION + SELECT user_a_id FROM user_relationship + WHERE user_b_id = $1 AND user_a_id = ANY($2) AND state = ANY($3) + "#, + client_id, + users_to_validate, + &blocked_states_string_vec + ).fetch_all(&self.pool).await?; + let blocked_users: Vec = blocked_users_optional.into_iter().flatten().collect(); + Ok(blocked_users) + } + } \ No newline at end of file diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs index 67b2ba7..67d2c96 100644 --- a/src/rooms/handler.rs +++ b/src/rooms/handler.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use axum::{Extension, Json}; use axum::extract::{Multipart, Path, Query, State}; @@ -13,6 +14,7 @@ use crate::messaging::model::MessageDTO; use crate::model::{ChatRoom, ChatRoomWithUserDTO, NewRoom, RoomMember, RoomType, UploadResponse}; use crate::rooms::room_service::RoomService; use crate::rooms::timeline_service::TimelineService; +use crate::user_relationship::user_service::UserService; use crate::utils::check_user_in_room; #[derive(Deserialize, Debug)] @@ -80,18 +82,32 @@ pub async fn mark_room_as_read( pub async fn handle_create_room( State(state): State>, Extension(token): Extension>, - Json(payload): Json + Json(mut payload): Json ) -> Result, AppError> { if !payload.invited_users.contains(&token.subject) { return Err(AppError::ValidationError("Sender ID is not in the list of invited users.".to_string())); } + + + //filter out all users that have an ignore-relationship with the sender + let ignored = UserService::get_blocked_users(state.clone(), &token.subject, &payload.invited_users).await?; + let filter_set: HashSet<_> = ignored.iter().collect(); + payload.invited_users.retain(|uuid| !filter_set.contains(uuid)); + match payload.room_type { RoomType::Single => { if payload.invited_users.len() != 2 { return Err(AppError::ValidationError("Personal rooms must have exactly two IDs (sender + one other).".to_string())); } + let other_user = payload.invited_users.iter().find(|&&el| el != token.subject).ok_or_else(|| { + AppError::ValidationError("Personal rooms must contain another user.".to_string()) + })?; + let has_active_chat = RoomService::find_existing_single_room(state.clone(), &token.subject, other_user).await?; + if has_active_chat.is_some() { + return Err(AppError::ValidationError("User already has an active personal chat.".to_string())); + } } RoomType::Group => { if payload.invited_users.len() < 2 { @@ -126,18 +142,23 @@ pub async fn handle_invite_to_room( State(state): State>, Path((room_id, user_id)): Path<(Uuid, Uuid)> ) -> Result<(), AppError> { + + let ignored = UserService::get_blocked_users(state.clone(), &token.subject, &vec!(user_id)).await?; + if ignored.contains(&user_id) { + return Err(AppError::Blocked("User is blocked.".to_string())); + } + RoomService::invite_to_room(state, token.subject, room_id, user_id).await?; Ok(()) } - pub async fn handle_search_existing_single_room( Extension(token): Extension>, State(state): State>, Query(params): Query, ) -> Result>, AppError> { - let result = RoomService::find_existing_single_room(state, token.subject, params.with_user).await?; + let result = RoomService::find_existing_single_room(state, &token.subject, ¶ms.with_user).await?; Ok(Json(result)) } diff --git a/src/rooms/mod.rs b/src/rooms/mod.rs index 537b058..9723d24 100644 --- a/src/rooms/mod.rs +++ b/src/rooms/mod.rs @@ -1,4 +1,4 @@ pub mod routes; mod timeline_service; mod handler; -mod room_service; \ No newline at end of file +pub mod room_service; \ No newline at end of file diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 877a548..6c8b265 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -178,8 +178,8 @@ impl RoomService { Ok(()) } - pub async fn find_existing_single_room(state: Arc, client_id: Uuid, with_user: Uuid) -> Result, AppError> { - let room_id = state.room_repository.find_room_between_users(&client_id, &with_user).await?; + pub async fn find_existing_single_room(state: Arc, client_id: &Uuid, with_user: &Uuid) -> Result, AppError> { + let room_id = state.room_repository.find_room_between_users(client_id, with_user).await?; Ok(room_id) } diff --git a/src/user_relationship/handler.rs b/src/user_relationship/handler.rs index cdbff53..9eb0b49 100644 --- a/src/user_relationship/handler.rs +++ b/src/user_relationship/handler.rs @@ -6,6 +6,7 @@ use crate::core::AppState; use crate::core::cursor::{decode_cursor, CursorResults}; use crate::errors::{AppError}; use crate::keycloak::decode::KeycloakToken; +use crate::rooms::room_service::RoomService; use crate::user_relationship::model::{User, UserPaginationCursor, UserWithRelationshipDto}; use crate::user_relationship::query_param::UserSearchParams; use crate::user_relationship::user_service::UserService; @@ -73,6 +74,9 @@ pub async fn handle_add_friend( Extension(token): Extension>, ) -> Result<(), AppError> { + if token.subject == user_id { + return Err(AppError::ValidationError("Cannot friendship yourself.".to_string())); + } UserService::add_friend(state, token.subject, user_id).await?; Ok(()) } @@ -110,7 +114,15 @@ pub async fn handle_ignore_user( Path(user_id): Path, Extension(token): Extension>, )-> Result<(), AppError> { - UserService::ignore_user(state, token.subject, user_id).await?; + + if token.subject == user_id { + return Err(AppError::ValidationError("Cannot ignore yourself.".to_string())); + } + UserService::ignore_user(state.clone(), token.subject.clone(), user_id.clone()).await?; + let room = RoomService::find_existing_single_room(state.clone(), &token.subject, &user_id).await?; + if let Some(room) = room { + RoomService::leave_room(state, token.subject, room).await?; + } Ok(()) } diff --git a/src/user_relationship/mod.rs b/src/user_relationship/mod.rs index 58823b0..f39dc0b 100644 --- a/src/user_relationship/mod.rs +++ b/src/user_relationship/mod.rs @@ -3,4 +3,4 @@ mod utils; mod handler; pub mod routes; mod query_param; -mod user_service; \ No newline at end of file +pub mod user_service; \ No newline at end of file diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs index 9c42093..89f9e47 100644 --- a/src/user_relationship/user_service.rs +++ b/src/user_relationship/user_service.rs @@ -357,4 +357,13 @@ impl UserService { Ok(()) } + pub async fn get_blocked_users( + state: Arc, + current_user_id: &Uuid, + users_to_validate: &Vec + ) -> Result, AppError> { + let users = state.user_repository.find_blocked_relationships(current_user_id, users_to_validate).await?; + Ok(users) + } + } \ No newline at end of file From 1d289e4abc4a0e8869f1f63b040812935ad2577c Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Wed, 5 Nov 2025 00:27:24 +0100 Subject: [PATCH 09/23] Add input validation for messaging API using `validator` library --- .idea/workspace.xml | 44 +++++++++++++++------------------ Cargo.lock | 53 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/errors.rs | 22 +++++++++++++++-- src/messaging/handler.rs | 2 ++ src/messaging/model.rs | 26 ++++++++++++++++---- 6 files changed, 117 insertions(+), 31 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index bd18ae9..c6ee96c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,17 +12,13 @@ - + - - - - - - - - - + + + + + - + @@ -810,7 +806,6 @@ - @@ -835,7 +830,8 @@ - diff --git a/Cargo.lock b/Cargo.lock index db22d11..ff4c09b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1947,6 +1947,7 @@ dependencies = [ "typed-builder", "url", "uuid", + "validator", ] [[package]] @@ -2844,6 +2845,28 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -4758,6 +4781,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.10", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c273521..39a9e22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ minio = { version = "0.3.0", features = ["default"] } image = { version = "0.25.8"} bytes = "1.10.1" base64 = "0.22.1" +validator = { version = "0.20.0", features = ["derive"] } #keycloak: atomic-time = "0.1.5" diff --git a/src/errors.rs b/src/errors.rs index 688c07d..c1b2989 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use axum::Json; use axum::response::{IntoResponse, Response}; use chrono::Utc; use serde::Serialize; +use validator::ValidationErrors; #[derive(Serialize)] pub struct ErrorResponse { @@ -123,9 +124,11 @@ pub enum AppError { ProcessingError(String), Blocked(String), - + S3Error(String), - + + BadRequest(String), + } impl fmt::Debug for AppError { @@ -137,6 +140,7 @@ impl fmt::Debug for AppError { Self::ProcessingError(msg) => write!(f, "ProcessingError: {}", msg), Self::Blocked(msg) => write!(f, "Blocked: {}", msg), Self::S3Error(msg) => write!(f, "S3Error: {}", msg), + Self::BadRequest(msg) => write!(f, "BadRequest: {}", msg), } } } @@ -150,6 +154,7 @@ impl Display for AppError { AppError::ProcessingError(msg) => write!(f, "Ein Verarbeitungsfehler ist aufgetreten: {}", msg), AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), AppError::S3Error(msg) => write!(f, "S3Error: {}", msg), + AppError::BadRequest(msg) => write!(f, "BadRequest: {}", msg), } } } @@ -175,6 +180,12 @@ impl Error for AppError { } } +impl From for AppError { + fn from(errors: ValidationErrors) -> Self { + AppError::BadRequest(errors.to_string()) + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { @@ -215,6 +226,13 @@ impl IntoResponse for AppError { msg ) } + AppError::BadRequest(msg) => { + HttpError::new( + StatusCode::BAD_REQUEST, + ErrorCode::ValidationError, + msg + ) + } }; http_error.into_response() diff --git a/src/messaging/handler.rs b/src/messaging/handler.rs index 3cb61ed..cd531d7 100644 --- a/src/messaging/handler.rs +++ b/src/messaging/handler.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use axum::{Extension, Json}; use axum::extract::State; +use validator::Validate; use crate::core::AppState; use crate::errors::AppError; use crate::keycloak::decode::KeycloakToken; @@ -13,6 +14,7 @@ pub async fn handle_send_message( Json(payload): Json ) -> Result, AppError> { + payload.validate().map_err(AppError::from)?; let response_msg = MessageService::send_message(state, payload, token.subject).await?; Ok(Json(response_msg)) } \ No newline at end of file diff --git a/src/messaging/model.rs b/src/messaging/model.rs index cd58ce8..78c02bc 100644 --- a/src/messaging/model.rs +++ b/src/messaging/model.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use validator::Validate; use crate::model::RoomMember; #[derive(Debug, Deserialize, Serialize, Clone)] @@ -48,7 +49,7 @@ impl Message { }; Ok(msg) } - + pub fn to_dto(&self) -> Result> { let message = MessageDTO { chat_room_id: self.chat_room_id, @@ -96,16 +97,19 @@ pub enum MessageBody { RoomChange(RoomChangeBody) } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct TextBody { + #[validate(length(min = 1, max = 4000, message = "must be between 1 and 4000 characters long."))] pub text: String, } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct MediaBody { + #[validate(length(min = 1, max = 250, message = "must be between 1 and 250 characters long."))] pub media_url: String, + #[validate(length(min = 1, max = 80, message = "must be between 1 and 80 characters long."))] pub media_type: String, pub mime_type: Option, pub alt_text: Option, @@ -140,10 +144,11 @@ pub enum RoomChangeBody { } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct NewMessage { pub chat_room_id: Uuid, + #[validate(nested)] pub msg_body: NewMessageBody, pub msg_type: MsgType } @@ -156,11 +161,22 @@ pub enum NewMessageBody { Reply(NewReplyBody) } -#[derive(Deserialize, Serialize, Debug, Clone)] +impl Validate for NewMessageBody { + fn validate(&self) -> Result<(), validator::ValidationErrors> { + match self { + NewMessageBody::Text(body) => body.validate(), + NewMessageBody::Media(body) => body.validate(), + NewMessageBody::Reply(body) => body.validate(), + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct NewReplyBody { pub reply_msg_id: Uuid, pub reply_created_at: DateTime, + #[validate(length(min = 1, max = 4000, message = "must be between 1 and 4000 characters long."))] pub reply_text: String } From 0166275024819d45880e43f92ffe09a7d5a157b1 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Wed, 5 Nov 2025 01:10:17 +0100 Subject: [PATCH 10/23] Add ConnectionGuard for automatic SSE unsubscription and improve user event handling --- .idea/workspace.xml | 33 ++++++++++++--------------- src/broadcast/event_broadcast.rs | 15 ++++++++++-- src/messaging/notifications.rs | 39 ++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index c6ee96c..0e576b1 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,13 +12,10 @@ - + - - - - - + + @@ -806,7 +803,6 @@ - @@ -831,7 +827,8 @@ - diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 58a7c7a..a142e23 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -19,7 +19,7 @@ impl BroadcastChannel { pub async fn init() { BROADCAST_INSTANCE.get_or_init(|| async { let channel = Arc::new(BroadcastChannel::new()); - channel.clone().start_cleanup_task(); + //channel.clone().start_cleanup_task(); channel }).await; } @@ -39,6 +39,7 @@ impl BroadcastChannel { } } + #[allow(dead_code)] fn start_cleanup_task(self: Arc) { tokio::spawn(async move { let mut interval = interval(Duration::from_secs(60)); @@ -95,6 +96,8 @@ impl BroadcastChannel { error!("Unable to broadcast notification: {}", err); } } + } else { + //todo: send notification or save to redis? } } } @@ -102,6 +105,14 @@ impl BroadcastChannel { pub async fn unsubscribe(&self, user_id: Uuid) { let mut lock = self.channel.write().await; - lock.remove(&user_id); + if let Some(sender) = lock.get(&user_id) { + if sender.receiver_count() > 0 { + return + } else { + lock.remove(&user_id); + debug!("Removed stale sender for user {:?}", user_id); + } + } } + } diff --git a/src/messaging/notifications.rs b/src/messaging/notifications.rs index edfd229..38fc2ed 100644 --- a/src/messaging/notifications.rs +++ b/src/messaging/notifications.rs @@ -6,9 +6,23 @@ use futures::Stream; use log::error; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use uuid::Uuid; use crate::broadcast::{BroadcastChannel}; use crate::keycloak::decode::KeycloakToken; +struct ConnectionGuard { + user_id: Uuid, +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { //triggering an unsubscribe, functions like a destructor + let user_id = self.user_id.clone(); + tokio::spawn(async move { + BroadcastChannel::get().unsubscribe(user_id).await; + }); + } +} + pub async fn stream_server_events( Extension(token): Extension> @@ -17,18 +31,25 @@ pub async fn stream_server_events( use futures::StreamExt; let receiver = BroadcastChannel::get().subscribe_to_user_events(token.subject.clone()).await; + let _guard = ConnectionGuard { user_id: token.subject.clone() }; - let stream = BroadcastStream::new(receiver).filter_map(move |notification| async move { - match notification { - Ok(event) => { - let sse = Event::default().data(serde_json::to_string(&event).unwrap()); - Some(Ok(sse)) - } - Err(error) => { - error!("{}", error); - None + let stream = BroadcastStream::new(receiver).filter_map(move |notification| { + + let _moved_guard = &_guard; //lifetime of guard is extended to the stream and will end when the sse connection is closed + + async move { + match notification { + Ok(event) => { + let sse = Event::default().data(serde_json::to_string(&event).unwrap()); + Some(Ok(sse)) + } + Err(error) => { + error!("{}", error); + None + } } } + }); Sse::new(stream).keep_alive( axum::response::sse::KeepAlive::new() From 26d4112d0f74cdea0c6190c5cc9a8feccd1d9f26 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Wed, 5 Nov 2025 23:51:19 +0100 Subject: [PATCH 11/23] Refactor relationship handling to improve consistency and return meaningful states --- .idea/workspace.xml | 47 +++++++---- src/broadcast/event_broadcast.rs | 19 +++++ src/errors.rs | 4 +- src/repository/user_repository.rs | 16 ++-- src/rooms/room_service.rs | 2 +- src/user_relationship/handler.rs | 22 ++++-- src/user_relationship/model.rs | 110 +++++++++++++++----------- src/user_relationship/routes.rs | 3 +- src/user_relationship/user_service.rs | 43 +++++----- 9 files changed, 171 insertions(+), 95 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 0e576b1..eae4fe2 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,10 +12,16 @@ - + - + + + + + + + @@ -803,7 +811,6 @@ - @@ -828,7 +835,19 @@ - + + + + + file://$PROJECT_DIR$/src/user_relationship/user_service.rs + 360 + + + diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index a142e23..7e44d80 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -10,6 +10,25 @@ use crate::broadcast::Notification; static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new(); +/// A `BroadcastChannel` struct is responsible for managing a collection of channels that are used +/// for broadcasting notifications to subscribers. Each channel is uniquely identified by a `Uuid`, +/// and messages are sent through a `Sender`. +/// +/// The struct uses an `RwLock` for thread-safe, concurrent access to the underlying `HashMap`. +/// +/// # Fields +/// - `channel`: An `RwLock`-protected `HashMap` that maps a `Uuid` (unique identifier) to a `Sender`. +/// - `Uuid`: A unique identifier for each channel. +/// - `Sender`: A sender handle for sending `Notification` messages to the corresponding receiver. +/// +/// The `BroadcastChannel` is designed to support multi-threaded operations where multiple threads +/// may add, retrieve, or remove channels or broadcast messages safely. +/// +/// +/// # Thread Safety +/// The usage of `RwLock` ensures that the operations on the `HashMap` are synchronized +/// and can safely be used across multiple threads. Readers can access the map concurrently, +/// while write operations are exclusive to ensure data integrity. pub struct BroadcastChannel { channel: RwLock>> } diff --git a/src/errors.rs b/src/errors.rs index c1b2989..f20072d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -237,4 +237,6 @@ impl IntoResponse for AppError { http_error.into_response() } -} \ No newline at end of file +} + +pub type AppResponse = Result; \ No newline at end of file diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 958a246..32dc5ef 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -169,7 +169,7 @@ impl UserRepository { Ok(relationship) } - pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: UserRelationshipEntity) -> Result<(), Error> { + pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: &UserRelationshipEntity) -> Result<(), Error> { sqlx::query!( r#" INSERT INTO user_relationship (user_a_id, user_b_id, state, relationship_change_timestamp) @@ -189,18 +189,24 @@ impl UserRepository { user_a_id: &Uuid, user_b_id: &Uuid, new_state: RelationshipState, - ) -> Result<(), sqlx::Error> { - sqlx::query!( + ) -> Result { + let entity = sqlx::query_as!( + UserRelationshipEntity, r#" UPDATE user_relationship SET state = $1, relationship_change_timestamp = NOW() WHERE user_a_id = $2 AND user_b_id = $3 + RETURNING + user_a_id, + user_b_id, + state as "state: RelationshipState", + relationship_change_timestamp "#, new_state.to_string(), user_a_id, user_b_id - ).execute(&mut *conn).await?; - Ok(()) + ).fetch_one(&mut *conn).await?; + Ok(entity) } pub async fn delete_relationship_state( diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 6c8b265..2f2fcde 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -199,7 +199,7 @@ impl RoomService { state.room_repository.update_room_img_url(&room_id, &object_id).await?; let response = UploadResponse { image_url: object_id.clone(), - image_name: format!("{}.png", object_id), + image_name: format!("{}.jpeg", object_id), }; Ok(response) } diff --git a/src/user_relationship/handler.rs b/src/user_relationship/handler.rs index 9eb0b49..585d449 100644 --- a/src/user_relationship/handler.rs +++ b/src/user_relationship/handler.rs @@ -4,10 +4,10 @@ use axum::{Extension, Json}; use uuid::Uuid; use crate::core::AppState; use crate::core::cursor::{decode_cursor, CursorResults}; -use crate::errors::{AppError}; +use crate::errors::{AppError, AppResponse}; use crate::keycloak::decode::KeycloakToken; use crate::rooms::room_service::RoomService; -use crate::user_relationship::model::{User, UserPaginationCursor, UserWithRelationshipDto}; +use crate::user_relationship::model::{RelationshipStateResponse, User, UserPaginationCursor, UserWithRelationshipDto}; use crate::user_relationship::query_param::UserSearchParams; use crate::user_relationship::user_service::UserService; @@ -113,24 +113,30 @@ pub async fn handle_ignore_user( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -)-> Result<(), AppError> { +)-> AppResponse> { if token.subject == user_id { return Err(AppError::ValidationError("Cannot ignore yourself.".to_string())); } - UserService::ignore_user(state.clone(), token.subject.clone(), user_id.clone()).await?; + let updated_state = UserService::ignore_user(state.clone(), token.subject.clone(), user_id.clone()).await?; let room = RoomService::find_existing_single_room(state.clone(), &token.subject, &user_id).await?; if let Some(room) = room { RoomService::leave_room(state, token.subject, room).await?; } - Ok(()) + let response = RelationshipStateResponse { + state: Some(updated_state) + }; + Ok(Json(response)) } pub async fn handle_undo_ignore_user( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -)-> Result<(), AppError> { - UserService::undo_ignore(state, token.subject, user_id).await?; - Ok(()) +)-> AppResponse> { + let updated_state = UserService::undo_ignore(state, token.subject, user_id).await?; + let response = RelationshipStateResponse { + state: updated_state + }; + Ok(Json(response)) } \ No newline at end of file diff --git a/src/user_relationship/model.rs b/src/user_relationship/model.rs index 785b69f..962ce72 100644 --- a/src/user_relationship/model.rs +++ b/src/user_relationship/model.rs @@ -21,91 +21,103 @@ pub struct UserRelationshipEntity { pub relationship_change_timestamp: DateTime } -#[derive(Debug)] -pub struct UserWithRelationshipEntity { - pub r_user: User, - user_a_id: Option, - user_b_id: Option, - relationship_state: Option, - relationship_change_timestamp: Option>, -} - -impl UserWithRelationshipEntity { - - pub fn get_relationship(&self) -> Option { - if self.user_a_id.is_some() && self.user_b_id.is_some() && self.relationship_state.is_some() && self.relationship_change_timestamp.is_some() { - Some(UserRelationshipEntity { - user_a_id: self.user_a_id.unwrap(), - user_b_id: self.user_b_id.unwrap(), - state: self.relationship_state.clone().unwrap(), - relationship_change_timestamp: self.relationship_change_timestamp.unwrap(), - }) - } else { - None - } - } - - pub fn to_dto(&self, client_id: &Uuid) -> UserWithRelationshipDto { - UserWithRelationshipDto { - user: self.r_user.clone(), - relationship_type: self.resolve_relationship_state(client_id), - } - } +impl UserRelationshipEntity { pub fn resolve_relationship_state( &self, client_id: &Uuid - ) -> Option { + ) -> Relationship { + + let relationship = self; - let relationship = self.get_relationship()?; - match relationship.state { - RelationshipState::FRIEND => Some(Relationship::Friend), + RelationshipState::FRIEND => Relationship::Friend, RelationshipState::A_BLOCKED => { if relationship.user_a_id == *client_id { - Some(Relationship::ClientBlocked) - } else { - Some(Relationship::ClientGotBlocked) + Relationship::ClientBlocked + } else { + Relationship::ClientGotBlocked } } RelationshipState::B_BLOCKED => { if relationship.user_b_id == *client_id { - Some(Relationship::ClientBlocked) + Relationship::ClientBlocked } else { - Some(Relationship::ClientGotBlocked) + Relationship::ClientGotBlocked } } RelationshipState::ALL_BLOCKED => { if relationship.user_b_id == *client_id || relationship.user_a_id == *client_id { - Some(Relationship::ClientBlocked) + Relationship::ClientBlocked } else { - Some(Relationship::ClientGotBlocked) + Relationship::ClientGotBlocked } } RelationshipState::A_INVITED => { if relationship.user_a_id == *client_id { - Some(Relationship::InviteSent) + Relationship::InviteSent } else { - Some(Relationship::InviteReceived) + Relationship::InviteReceived } } RelationshipState::B_INVITED => { if relationship.user_b_id == *client_id { - Some(Relationship::InviteSent) + Relationship::InviteSent } else { - Some(Relationship::InviteReceived) + Relationship::InviteReceived } } } } } + +#[derive(Debug)] +pub struct UserWithRelationshipEntity { + pub r_user: User, + user_a_id: Option, + user_b_id: Option, + relationship_state: Option, + relationship_change_timestamp: Option>, +} + + +impl UserWithRelationshipEntity { + + pub fn get_relationship(&self) -> Option { + if self.user_a_id.is_some() && self.user_b_id.is_some() && self.relationship_state.is_some() && self.relationship_change_timestamp.is_some() { + Some(UserRelationshipEntity { + user_a_id: self.user_a_id.unwrap(), + user_b_id: self.user_b_id.unwrap(), + state: self.relationship_state.clone().unwrap(), + relationship_change_timestamp: self.relationship_change_timestamp.unwrap(), + }) + } else { + None + } + } + + pub fn to_dto(&self, client_id: &Uuid) -> UserWithRelationshipDto { + + let rel_type = match self.get_relationship() { + Some(rel) => Some(rel.resolve_relationship_state(client_id)), + None => None + }; + + UserWithRelationshipDto { + user: self.r_user.clone(), + relationship_type: rel_type, + } + } + +} + impl<'r, R: Row> FromRow<'r, R> for UserWithRelationshipEntity where &'r str: sqlx::ColumnIndex, @@ -149,7 +161,7 @@ pub struct UserWithRelationshipDto { #[allow(non_camel_case_types)] -#[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq, Copy)] #[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RelationshipState { A_BLOCKED, @@ -225,4 +237,10 @@ pub struct User { pub struct UserPaginationCursor { pub last_seen_name: Option, pub last_seen_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelationshipStateResponse { + pub state: Option } \ No newline at end of file diff --git a/src/user_relationship/routes.rs b/src/user_relationship/routes.rs index ffc9835..0a66d5d 100644 --- a/src/user_relationship/routes.rs +++ b/src/user_relationship/routes.rs @@ -17,6 +17,5 @@ pub fn create_user_routes() -> Router> { .route("/api/users/friends/{friend_id}", delete(handle_remove_friend)) .route("/api/users/ignore/{user_id}", post(handle_ignore_user)) .route("/api/users/ignore/{user_id}", delete(handle_undo_ignore_user)) - - + } \ No newline at end of file diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs index 89f9e47..86fb6c1 100644 --- a/src/user_relationship/user_service.rs +++ b/src/user_relationship/user_service.rs @@ -6,7 +6,7 @@ use crate::broadcast::NotificationEvent::{FriendRequestAccepted, FriendRequestRe use crate::core::AppState; use crate::core::cursor::{encode_cursor, CursorResults}; use crate::errors::{AppError}; -use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipDto}; +use crate::user_relationship::model::{Relationship, RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipDto}; pub struct UserService; @@ -129,7 +129,7 @@ impl UserService { relationship_change_timestamp: Utc::now(), }; - state.user_repository.insert_relationship(&mut tx, init_relationship).await?; + state.user_repository.insert_relationship(&mut tx, &init_relationship).await?; tx.commit().await?; let client_dto = state.user_repository.find_user_by_id(&sender_id).await?.ok_or_else(|| { @@ -240,7 +240,7 @@ impl UserService { state: Arc, client_id: Uuid, ignored_user_id: Uuid, - ) -> Result<(), AppError> { + ) -> Result { let mut tx = state.user_repository.start_transaction().await?; let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &ignored_user_id).await?; @@ -249,9 +249,9 @@ impl UserService { let is_client_user_a = client_id == rel.user_a_id; let new_state = match (rel.state, is_client_user_a) { - (RelationshipState::ALL_BLOCKED, _) => return Ok(()), //Both blocked - (RelationshipState::A_BLOCKED, true) => return Ok(()), //client is A and blocked B - (RelationshipState::B_BLOCKED, false) => return Ok(()), //client is B and blocked A + (RelationshipState::ALL_BLOCKED, _) => return Ok(Relationship::ClientBlocked), //Both blocked + (RelationshipState::A_BLOCKED, true) => return Ok(Relationship::ClientBlocked), //client is A and blocked B + (RelationshipState::B_BLOCKED, false) => return Ok(Relationship::ClientBlocked), //client is B and blocked A (RelationshipState::A_BLOCKED, false) => RelationshipState::ALL_BLOCKED, (RelationshipState::B_BLOCKED, true) => RelationshipState::ALL_BLOCKED, (RelationshipState::FRIEND, _) => { @@ -272,12 +272,14 @@ impl UserService { } } }; - state.user_repository.update_relationship_state( + let entity = state.user_repository.update_relationship_state( &mut tx, &rel.user_a_id, &rel.user_b_id, new_state ).await?; + tx.commit().await?; + Ok(entity.resolve_relationship_state(&client_id)) } else { //no relationship found, create one let (user_a_id, user_b_id) = if client_id < ignored_user_id { (client_id, ignored_user_id) @@ -294,21 +296,20 @@ impl UserService { let init_relationship = UserRelationshipEntity { user_a_id, user_b_id, - state: relationship_state, + state: relationship_state.clone(), relationship_change_timestamp: Utc::now(), }; - state.user_repository.insert_relationship(&mut tx, init_relationship).await?; + state.user_repository.insert_relationship(&mut tx, &init_relationship).await?; + tx.commit().await?; + Ok(init_relationship.resolve_relationship_state(&client_id)) } - - tx.commit().await?; - Ok(()) } pub async fn undo_ignore( state: Arc, client_id: Uuid, ignored_user_id: Uuid, - ) -> Result<(), AppError> { + ) -> Result, AppError> { let mut tx = state.user_repository.start_transaction().await?; let relationship = state .user_repository @@ -318,22 +319,24 @@ impl UserService { AppError::NotFound("No block relationship found to undo.".to_string()) })?; let is_client_user_a = client_id == relationship.user_a_id; - match (relationship.state.clone(), is_client_user_a) { + let state = match (relationship.state.clone(), is_client_user_a) { (RelationshipState::ALL_BLOCKED, true) => { // Client was A, only B blocking now - state.user_repository.update_relationship_state( + let entity = state.user_repository.update_relationship_state( &mut tx, &relationship.user_a_id, &relationship.user_b_id, RelationshipState::B_BLOCKED, ).await?; + Some(entity) }, (RelationshipState::ALL_BLOCKED, false) => { // Client was B, only A blocking now - state.user_repository.update_relationship_state( + let entity = state.user_repository.update_relationship_state( &mut tx, &relationship.user_a_id, &relationship.user_b_id, RelationshipState::A_BLOCKED, ).await?; + Some(entity) }, (RelationshipState::A_BLOCKED, true) | (RelationshipState::B_BLOCKED, false) => { // Fall 2: only client blocked, remove relationship @@ -341,6 +344,7 @@ impl UserService { &mut tx, relationship ).await?; + None }, (RelationshipState::A_BLOCKED, false) | (RelationshipState::B_BLOCKED, true) => { //client was blocked by another user return Err(AppError::Blocked( @@ -352,9 +356,12 @@ impl UserService { "No active block from your side found to undo.".to_string(), )); } + }; + tx.commit().await?; + match state { + Some(entity) => { Ok(Some(entity.resolve_relationship_state(&client_id))) }, + None => Ok(None) } - - Ok(()) } pub async fn get_blocked_users( From 20748865c82437641a06b11686ed0dae362a033a Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Wed, 5 Nov 2025 23:51:47 +0100 Subject: [PATCH 12/23] Update user_relationship query to return updated state and timestamp fields --- ...76627c07d75a2dc68e66a99e3c36e4e5ec01e.json | 42 +++++++++++++++++++ ...61d64b7f3b697f5d59b42cf65471de70fa100.json | 16 ------- 2 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 .sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json delete mode 100644 .sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json diff --git a/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json b/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json new file mode 100644 index 0000000..5fd83f2 --- /dev/null +++ b/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_relationship\n SET state = $1, relationship_change_timestamp = NOW()\n WHERE user_a_id = $2 AND user_b_id = $3\n RETURNING\n user_a_id,\n user_b_id,\n state as \"state: RelationshipState\",\n relationship_change_timestamp\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_a_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_b_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state: RelationshipState", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "relationship_change_timestamp", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e" +} diff --git a/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json b/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json deleted file mode 100644 index 37765e5..0000000 --- a/.sqlx/query-b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE user_relationship\n SET state = $1, relationship_change_timestamp = NOW()\n WHERE user_a_id = $2 AND user_b_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "b9b2f8a27d0033f16edd4e2504f61d64b7f3b697f5d59b42cf65471de70fa100" -} From 3ded26003a4648070899d8c3dc87bf428daef844 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Thu, 6 Nov 2025 18:21:49 +0100 Subject: [PATCH 13/23] Add Redis cache support for notifications and improve scalability Integrates Redis for caching user notifications, including background cleanup tasks and fallback mechanisms. Updates configuration, Docker setup, and application state to support caching features, enhancing performance and reliability. --- .idea/workspace.xml | 107 +++++++++++++++---------------- Cargo.lock | 51 +++++++++++++++ Cargo.toml | 3 + compose.yaml | 11 ++++ development.config.toml | 2 + src/broadcast/event_broadcast.rs | 16 +++-- src/cache/cache_cleanup.rs | 85 ++++++++++++++++++++++++ src/cache/mod.rs | 2 + src/cache/redis_cache.rs | 99 ++++++++++++++++++++++++++++ src/core/app_state.rs | 25 +++++++- src/core/config.rs | 1 + src/lib.rs | 4 +- src/main.rs | 12 ++-- src/messaging/notifications.rs | 29 +++++++-- 14 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 src/cache/cache_cleanup.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/redis_cache.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index eae4fe2..6a29193 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,16 +12,21 @@ - + + + + + + + + - - - - - - - + + + + + - { "associatedIndex": 3 @@ -82,6 +87,7 @@ "Docker.Dockerfile.executor": "Run", "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", "Docker.compose.yaml.console: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redis: Compose Deployment.executor": "Run", "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", "Docker.compose.yaml: Compose Deployment.executor": "Run", "RunOnceActivity.ShowReadmeOnStart": "true", @@ -162,17 +168,6 @@ @@ -396,23 +404,9 @@ - - - - - - - @@ -811,8 +821,6 @@ - - @@ -836,18 +844,9 @@ - - - - - - file://$PROJECT_DIR$/src/user_relationship/user_service.rs - 360 - - - + + + diff --git a/Cargo.lock b/Cargo.lock index ff4c09b..f44913b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -609,6 +615,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1911,6 +1931,7 @@ name = "ism" version = "0.7.0" dependencies = [ "assertr", + "async-trait", "atomic-time", "axum", "base64 0.22.1", @@ -1926,6 +1947,7 @@ dependencies = [ "log", "minio", "nonempty", + "redis", "reqwest", "samsa", "scylla", @@ -3119,6 +3141,29 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redis" +version = "1.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3c1983f96fe1aa42d3e75d6eedc0374ba45f784fb86f130e2c8dac95817471" +dependencies = [ + "arcstr", + "bytes", + "cfg-if", + "combine", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.1", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -3706,6 +3751,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.8" diff --git a/Cargo.toml b/Cargo.toml index 39a9e22..b79736a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ image = { version = "0.25.8"} bytes = "1.10.1" base64 = "0.22.1" validator = { version = "0.20.0", features = ["derive"] } +redis = { version = "1.0.0-rc.3", features = ["tokio-comp"] } + #keycloak: atomic-time = "0.1.5" @@ -42,6 +44,7 @@ time = "0.3.41" try-again = "0.2.2" typed-builder = "0.23.0" url = "2.5.7" +async-trait = "0.1.89" [features] default = ["default-tls", "reqwest/charset", "reqwest/http2", "reqwest/macos-system-configuration"] diff --git a/compose.yaml b/compose.yaml index f367499..6332fcc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,15 @@ services: volumes: - cassandra-data:/var/lib/cassandra + redis: + image: 'redis:8.2.3-alpine' + container_name: redis-cache + restart: always + ports: + - '6379:6379' + volumes: + - redis_data:/data + redpanda-0: command: - redpanda @@ -76,6 +85,8 @@ volumes: driver: local redpanda-0: driver: local + redis_data: + driver: local networks: redpanda_network: diff --git a/development.config.toml b/development.config.toml index f5ba1f8..da1b402 100644 --- a/development.config.toml +++ b/development.config.toml @@ -1,6 +1,8 @@ ism_url = "127.0.0.1" ism_port= 7800 use_kafka = false +redis_cache_url = "redis://127.0.0.1/" + [token_issuer] valid_admin_client = "api-client" \ No newline at end of file diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 7e44d80..0194714 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use tokio::sync::broadcast::{Sender, channel, Receiver}; use tokio::time::interval; use crate::broadcast::Notification; +use crate::cache::redis_cache::Cache; static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new(); @@ -30,15 +31,17 @@ static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new /// and can safely be used across multiple threads. Readers can access the map concurrently, /// while write operations are exclusive to ensure data integrity. pub struct BroadcastChannel { - channel: RwLock>> + channel: RwLock>>, + notification_cache: Arc } impl BroadcastChannel { - pub async fn init() { + pub async fn init(cache: Arc) { BROADCAST_INSTANCE.get_or_init(|| async { - let channel = Arc::new(BroadcastChannel::new()); + let channel = Arc::new(BroadcastChannel::new(cache)); //channel.clone().start_cleanup_task(); + info!("BroadcastChannel initialized."); channel }).await; } @@ -52,9 +55,10 @@ impl BroadcastChannel { } } - fn new() -> Self { + fn new(cache: Arc) -> Self { BroadcastChannel { channel: RwLock::new(HashMap::new()), + notification_cache: cache } } @@ -116,7 +120,9 @@ impl BroadcastChannel { } } } else { - //todo: send notification or save to redis? + if let Err(error) = self.notification_cache.add_notification_for_user(&user_id, ¬ification).await { + error!("Failed to cache notification: {}", error); + }; } } } diff --git a/src/cache/cache_cleanup.rs b/src/cache/cache_cleanup.rs new file mode 100644 index 0000000..760e2b0 --- /dev/null +++ b/src/cache/cache_cleanup.rs @@ -0,0 +1,85 @@ +use std::time::Duration; +use redis::aio::MultiplexedConnection; +use redis::{Client, RedisResult}; +use redis::{AsyncCommands}; +use tracing::{debug, error}; + +const MASTER_INDEX_SET: &str = "active_user_notification_indices"; + +pub async fn periodic_cleanup_task(client: Client) { + + let cleanup_interval = Duration::from_secs(3600); //atm each 1hr + + debug!("Starting Cache-Cleanup-Task."); + + loop { + tokio::time::sleep(cleanup_interval).await; + debug!("Starting periodic cache cleanup..."); + let mut con = match client.get_multiplexed_async_connection().await { + Ok(c) => c, + Err(e) => { + error!("Can't connect to cache: {}", e); + continue; + } + }; + + // getting all user ids from the master index set + let user_ids: Vec = match con.smembers(MASTER_INDEX_SET).await { + Ok(ids) => ids, + Err(e) => { + error!("Error trying to get all users of the master cache index: {}", e); + continue; + } + }; + + for user_id in user_ids { + if let Err(e) = cleanup_user_index(&mut con, &user_id).await { + error!("Error trying to cleanup the notification cache of user {}: {}", user_id, e); + } + } + debug!("Periodic cleanup finished."); + } +} + +async fn cleanup_user_index( + con: &mut MultiplexedConnection, + user_id: &str, +) -> RedisResult<()> { + let sorted_set_key = format!("user_notifications:{}", user_id); + + // 1. getting all notification key references from the sorted set of the user + let all_notification_keys: Vec = con.zrange(&sorted_set_key, 0, -1).await?; + + if all_notification_keys.is_empty() { + let _: isize = con.srem(MASTER_INDEX_SET, user_id).await?; //remove user from master index set if the set is empty + return Ok(()); + } + + let mut keys_to_remove = Vec::new(); + + // 2. Batch-Processing each key + for chunk in all_notification_keys.chunks(100usize) { + let mut pipe = redis::pipe(); + + // Validate the existence of the key int he k/v store + for key in chunk { + pipe.exists(key); + } + let existence_flags: Vec = pipe.query_async(con).await?; + + // push keys to remove to a list + for (key, exists) in chunk.iter().zip(existence_flags.iter()) { + if !*exists { + keys_to_remove.push(key); + } + } + } + + // 5. Remove all keys without k/v reference from the sorted set of the user + if !keys_to_remove.is_empty() { + let count: isize = con.zrem(&sorted_set_key, keys_to_remove).await?; + debug!("Cache cleanup for user {}: {} elements removed.", user_id, count); + } + + Ok(()) +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..4e9c68e --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,2 @@ +pub mod redis_cache; +pub mod cache_cleanup; \ No newline at end of file diff --git a/src/cache/redis_cache.rs b/src/cache/redis_cache.rs new file mode 100644 index 0000000..32a0870 --- /dev/null +++ b/src/cache/redis_cache.rs @@ -0,0 +1,99 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use redis::{AsyncCommands, Client, ErrorKind, RedisError, RedisResult}; +use uuid::Uuid; +use crate::broadcast::Notification; + +const MASTER_INDEX_SET: &str = "active_user_notification_indices"; + +#[async_trait] +pub trait Cache: Send + Sync { + async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult>; + async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()>; +} + +//docs: https://docs.rs/redis/latest/redis/ +#[derive(Clone)] +pub struct RedisCache { + pub client: Client, +} + + +#[async_trait] +impl Cache for RedisCache { + + async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult> { + let mut con = self.client.get_multiplexed_async_connection().await?; + let sorted_set_key = format!("user_notifications:{}", user_id); + let min_score = latest_ts.timestamp(); + + let notification_keys: Vec = con + .zrangebyscore( + &sorted_set_key, + min_score, // timestamp of oldest notification + "+inf", // get all notifications + ) + .await?; + + if notification_keys.is_empty() { + return Ok(vec![]); + } + let notifications_json: Vec> = con.mget(¬ification_keys).await?; + let notifications: Vec = notifications_json + .into_iter() + .filter_map(|opt_json| opt_json) + .filter_map(|json| serde_json::from_str(&json).ok()) + .collect(); + + Ok(notifications) + } + + async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()> { + + let mut con = self.client.get_multiplexed_async_connection().await?; + let notification_key = format!("notification:{}", Uuid::new_v4()); + let notification_json = serde_json::to_string(notification) + .map_err(|err| { + RedisError::from(( + ErrorKind::Parse, + "Failed to serialize notification to JSON", + err.to_string(), + )) + })?; + + let score = notification.created_at.timestamp(); + let sorted_set_key = format!("user_notifications:{}", user_id); + + let mut pipe = redis::pipe(); //like a atomic transaction + pipe.atomic() + //add k/v string + .set_ex( + ¬ification_key, + notification_json, + 3600, //ttl is 60 minutes + ) + //add to sorted set from user + .zadd(&sorted_set_key, ¬ification_key, score) + //add to master index set, to track all user sets and remove them if they are empty + .sadd(MASTER_INDEX_SET, user_id.to_string()); + + pipe.exec_async(&mut con).await?; + Ok(()) + } + +} + + +//doing nothing, used when redis is not available: +pub struct NoOpCache; + +#[async_trait] +impl Cache for NoOpCache { + async fn get_notifications_for_user(&self, _user_id: &Uuid, _latest_ts: DateTime) -> RedisResult> { + Ok(vec![]) + } + async fn add_notification_for_user(&self, _user_id: &Uuid, _notification: &Notification) -> RedisResult<()> { + Ok(()) + } +} + diff --git a/src/core/app_state.rs b/src/core/app_state.rs index 985fb5f..8eff778 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -1,6 +1,10 @@ +use std::sync::Arc; use log::info; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::task; +use crate::broadcast::BroadcastChannel; +use crate::cache::cache_cleanup::periodic_cleanup_task; +use crate::cache::redis_cache::{Cache, NoOpCache, RedisCache}; use crate::core::ISMConfig; use crate::database::{MessageDatabase, ObjectStorage}; use crate::kafka::start_consumer; @@ -14,6 +18,7 @@ pub struct AppState { pub room_repository: RoomRepository, pub user_repository: UserRepository, pub message_repository: MessageDatabase, + pub cache: Arc, pub s3_bucket: ObjectStorage } @@ -41,13 +46,31 @@ impl AppState { } }; + let cache: Arc = match config.redis_cache_url.clone() { + Some(url) => { + let client = redis::Client::open(url) + .unwrap_or_else(|err| panic!("Unable to init redis cache: {}", err)); + info!("Established connection to the redis cache."); + tokio::spawn(periodic_cleanup_task(client.clone())); + Arc::new(RedisCache { client }) + }, + None => { + info!("Redis is deactivated. Initializing NoOpCache..."); + Arc::new(NoOpCache) + } + }; + + //init broadcaster channel + BroadcastChannel::init(cache.clone()).await; + //2. State struct: let state = Self { env: config.clone(), room_repository: RoomRepository::new(pool.clone()), user_repository: UserRepository::new(pool.clone()), message_repository: MessageDatabase::new(&config.message_db_config).await, - s3_bucket: ObjectStorage::new(&config.object_db_config).await + s3_bucket: ObjectStorage::new(&config.object_db_config).await, + cache: cache }; //3: kafka (optional) diff --git a/src/core/config.rs b/src/core/config.rs index 24c841d..5e2aebb 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -10,6 +10,7 @@ pub struct ISMConfig { pub use_kafka: bool, pub log_level: String, pub cors_origin: String, + pub redis_cache_url: Option, pub user_db_config: UserDbConfig, pub object_db_config: ObjectStorageConfig, pub message_db_config: MessageDbConfig, diff --git a/src/lib.rs b/src/lib.rs index 1b6d9f2..e3e74d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,8 @@ pub mod keycloak; pub mod repository; pub mod user_relationship; pub mod rooms; - pub mod messaging; pub mod utils; pub mod errors; -pub mod router; \ No newline at end of file +pub mod router; +pub mod cache; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index a5c7c32..dd7634c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ use tracing::info; use tracing_subscriber::EnvFilter; use ism::core::{AppState, ISMConfig}; use tracing_subscriber::filter::LevelFilter; -use ism::broadcast::BroadcastChannel; use ism::router::init_router; //learn to code rust axum here: @@ -15,18 +14,19 @@ use ism::router::init_router; //https://github.com/rust-lang/crates.io/ #[tokio::main(flavor = "multi_thread")] async fn main() { + let config = init_configuration(); - - //init broadcaster channel - BroadcastChannel::init().await; - //init the app state including database connections, kafka etc. + //init the app state including database connections, broadcast channels, kafka etc. let app_state = AppState::new(config.clone()).await; //init api router: let app = init_router(app_state).await; let url = format!("{}:{}", config.ism_url, config.ism_port); - let listener = TcpListener::bind(url.clone()).await.unwrap(); + let listener = TcpListener::bind(url.clone()) + .await + .unwrap_or_else(|err| panic!("Unable to start TCP-Listener at URL: {}, error is: {}", url, err)); + info!("ISM-Server up and is listening on: {url}"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal())//only working when there aren't active connections diff --git a/src/messaging/notifications.rs b/src/messaging/notifications.rs index 38fc2ed..d6188c8 100644 --- a/src/messaging/notifications.rs +++ b/src/messaging/notifications.rs @@ -1,13 +1,19 @@ +use std::sync::Arc; use std::time::Duration; use axum::{Extension, Json}; -use axum::response::{IntoResponse, Sse}; +use axum::extract::{Query, State}; +use axum::response::{Sse}; use axum::response::sse::Event; +use chrono::{DateTime, Utc}; use futures::Stream; use log::error; +use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use uuid::Uuid; -use crate::broadcast::{BroadcastChannel}; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::core::AppState; +use crate::errors::{AppError, AppResponse}; use crate::keycloak::decode::KeycloakToken; struct ConnectionGuard { @@ -58,8 +64,19 @@ pub async fn stream_server_events( ) } -pub async fn get_latest_notification_events() -> impl IntoResponse { - //todo: query latest events - //placeholder - Json::>(vec![]).into_response() +#[derive(Deserialize)] +pub struct NotificationQueryParam { + timestamp: DateTime +} + +pub async fn get_latest_notification_events( + State(state): State>, + Extension(token): Extension>, + Query(params): Query +) -> AppResponse>> { + + let notifications = state.cache.get_notifications_for_user(&token.subject, params.timestamp).await.map_err(|_| { + AppError::ProcessingError("Error getting notifications: Cache Error".to_string()) + })?; + Ok(Json(notifications)) } \ No newline at end of file From 73ed157cbaa808028157e96ed51cf83b9932d5f2 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Fri, 7 Nov 2025 16:37:07 +0100 Subject: [PATCH 14/23] fix env --- .env | 1 - .idea/workspace.xml | 117 ++++++++++++++++++++------------------------ 2 files changed, 53 insertions(+), 65 deletions(-) diff --git a/.env b/.env index b47f083..965362d 100644 --- a/.env +++ b/.env @@ -1,3 +1,2 @@ DATABASE_URL=postgresql://postgres:meventure1234@localhost:32768/postgres ISM_LOG_LEVEL=info -ISM_USER_DB_CONFIG__DB_HOST=localhost \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 6a29193..68535b3 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,21 +12,9 @@ - - - - + + - - - - - - - - - - - { + "keyToString": { + "Cargo.Run ISM.executor": "Run", + "Cargo.Run.executor": "Run", + "Cargo.sqlx.executor": "Run", + "Docker.Dockerfile runtime.executor": "Run", + "Docker.Dockerfile.executor": "Run", + "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", + "Docker.compose.yaml.console: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redis: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", + "Docker.compose.yaml: Compose Deployment.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.rust.reset.selective.auto.import": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "junie.onboarding.icon.badge.shown": "true", + "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", + "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", + "org.rust.first.attach.projects": "true", + "settings.editor.selected.configurable": "language.rust.cargo.check", + "to.speed.mode.migration.done": "true", + "vue.rearranger.settings.migration": "true" }, - "keyToStringList": { - "DatabaseDriversLRU": [ - "postgresql" + "keyToStringList": { + "DatabaseDriversLRU": [ + "postgresql" ] } -}]]> +} @@ -406,15 +394,8 @@ - - - - - @@ -821,7 +810,6 @@ - @@ -846,7 +834,8 @@ - From 56dd34014b71c3814bc1dce592867fce5d40630a Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Sun, 9 Nov 2025 22:00:11 +0100 Subject: [PATCH 15/23] Add role handling to user queries and improve message preview serialization Enhances database queries to include user roles and updates room message preview serialization for better context representation. Refactors related models and services to support new data structures and functionality. --- .idea/workspace.xml | 133 +++++++++++------- ...07800a3d7c97fdc823e88432052fa51eab2b.json} | 12 +- ...c26a18cdb9878c8e395510cc3c0c14bcf0c6.json} | 10 +- ...fef227e3f6f0e7e9e1d0d0b9899549fe2d415.json | 16 --- ...28e222517da03b7881fa33fa93e111c3880a.json} | 12 +- ...1b1e8355a11a27702dd6bbff8dfbdccfb436.json} | 10 +- ...69320be95c069369367c10f677d44d4152bd.json} | 10 +- ...f6aba2f314490466d6994d515699cafd2ca2f.json | 22 --- ...7c671cc246b82d3c4c55f9f116ef8007f87d.json} | 4 +- ...ba933c1aca8dfaf54f22c5edc23ca91cbd47a.json | 53 +++++++ ...ba38fefd1b72197a9da3d925ff2618523a346.json | 17 +++ src/broadcast/event_broadcast.rs | 4 + src/broadcast/notification.rs | 9 +- src/cache/cache_cleanup.rs | 3 +- src/cache/mod.rs | 3 +- src/cache/redis_cache.rs | 3 +- src/cache/util.rs | 3 + src/messaging/message_service.rs | 28 ++-- src/model/room.rs | 108 ++++++++++---- src/repository/room_repository.rs | 85 +++++++---- src/repository/user_repository.rs | 11 +- src/rooms/handler.rs | 8 +- src/rooms/room_service.rs | 85 ++++++----- src/user_relationship/model.rs | 3 +- src/utils.rs | 14 +- 25 files changed, 441 insertions(+), 225 deletions(-) rename .sqlx/{query-e5c42af39823f474b703f3ed0449280b18cfb8f2aeae4d7eb347e09c7c9c258a.json => query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json} (76%) rename .sqlx/{query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json => query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json} (58%) delete mode 100644 .sqlx/query-3cdb7869c622b1a72d81d09d8a7fef227e3f6f0e7e9e1d0d0b9899549fe2d415.json rename .sqlx/{query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json => query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json} (84%) rename .sqlx/{query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json => query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json} (66%) rename .sqlx/{query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json => query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json} (76%) delete mode 100644 .sqlx/query-d0779c2f67e104b4387537026a1f6aba2f314490466d6994d515699cafd2ca2f.json rename .sqlx/{query-b7fab8640b6c1bfd96b4349ebbaf6152fbe3a7d62a7c91fa094065eed27a15e9.json => query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json} (83%) create mode 100644 .sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json create mode 100644 .sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json create mode 100644 src/cache/util.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 68535b3..31fc6da 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,9 +12,30 @@ - - + + + + + + + + + + + + + + + + + + + + + + + - { - "keyToString": { - "Cargo.Run ISM.executor": "Run", - "Cargo.Run.executor": "Run", - "Cargo.sqlx.executor": "Run", - "Docker.Dockerfile runtime.executor": "Run", - "Docker.Dockerfile.executor": "Run", - "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", - "Docker.compose.yaml.console: Compose Deployment.executor": "Run", - "Docker.compose.yaml.redis: Compose Deployment.executor": "Run", - "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", - "Docker.compose.yaml: Compose Deployment.executor": "Run", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", - "RunOnceActivity.git.unshallow": "true", - "RunOnceActivity.rust.reset.selective.auto.import": "true", - "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true", - "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", - "git-widget-placeholder": "master", - "ignore.virus.scanning.warn.message": "true", - "junie.onboarding.icon.badge.shown": "true", - "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", - "node.js.detected.package.eslint": "true", - "node.js.detected.package.tslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", - "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", - "org.rust.first.attach.projects": "true", - "settings.editor.selected.configurable": "language.rust.cargo.check", - "to.speed.mode.migration.done": "true", - "vue.rearranger.settings.migration": "true" + +}]]> @@ -120,7 +141,7 @@ - + @@ -395,15 +416,11 @@ - - - - - @@ -810,7 +835,6 @@ - @@ -835,7 +859,8 @@ - diff --git a/.sqlx/query-e5c42af39823f474b703f3ed0449280b18cfb8f2aeae4d7eb347e09c7c9c258a.json b/.sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json similarity index 76% rename from .sqlx/query-e5c42af39823f474b703f3ed0449280b18cfb8f2aeae4d7eb347e09c7c9c258a.json rename to .sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json index 2e311b0..4e63790 100644 --- a/.sqlx/query-e5c42af39823f474b703f3ed0449280b18cfb8f2aeae4d7eb347e09c7c9c258a.json +++ b/.sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, room_type as \"room_type: RoomType\", room_name, created_at, latest_message, room_image_url, latest_message_preview_text\n FROM chat_room\n WHERE id = $1\n ", + "query": "\n SELECT id, room_type as \"room_type: RoomType\", room_name, created_at, latest_message, room_image_url, latest_message_preview_text, NULL::boolean as \"unread: _\"\n FROM chat_room\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "latest_message_preview_text", "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "unread: _", + "type_info": "Bool" } ], "parameters": { @@ -51,8 +56,9 @@ false, true, true, - true + true, + null ] }, - "hash": "e5c42af39823f474b703f3ed0449280b18cfb8f2aeae4d7eb347e09c7c9c258a" + "hash": "015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b" } diff --git a/.sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json b/.sqlx/query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json similarity index 58% rename from .sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json rename to .sqlx/query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json index 4e870b3..751422e 100644 --- a/.sqlx/query-3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132.json +++ b/.sqlx/query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count\n FROM\n app_user u\n INNER JOIN\n user_relationship rl ON u.id = (\n CASE\n WHEN rl.user_a_id = $1 THEN rl.user_b_id\n WHEN rl.user_b_id = $1 THEN rl.user_a_id\n ELSE NULL\n END\n )\n WHERE\n rl.state = $2\n ", + "query": "\n SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count,\n u.role\n FROM\n app_user u\n INNER JOIN\n user_relationship rl ON u.id = (\n CASE\n WHEN rl.user_a_id = $1 THEN rl.user_b_id\n WHEN rl.user_b_id = $1 THEN rl.user_a_id\n ELSE NULL\n END\n )\n WHERE\n rl.state = $2\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "friends_count", "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" } ], "parameters": { @@ -46,8 +51,9 @@ true, false, true, + false, false ] }, - "hash": "3aaa9068f9e71cce0b23ce8db90da0cc12d275c79850b6349bb783c661003132" + "hash": "2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6" } diff --git a/.sqlx/query-3cdb7869c622b1a72d81d09d8a7fef227e3f6f0e7e9e1d0d0b9899549fe2d415.json b/.sqlx/query-3cdb7869c622b1a72d81d09d8a7fef227e3f6f0e7e9e1d0d0b9899549fe2d415.json deleted file mode 100644 index 5d3f57f..0000000 --- a/.sqlx/query-3cdb7869c622b1a72d81d09d8a7fef227e3f6f0e7e9e1d0d0b9899549fe2d415.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO chat_room_participant (user_id, room_id, joined_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, room_id) DO UPDATE SET joined_at = $3, participant_state = 'Joined'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "3cdb7869c622b1a72d81d09d8a7fef227e3f6f0e7e9e1d0d0b9899549fe2d415" -} diff --git a/.sqlx/query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json b/.sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json similarity index 84% rename from .sqlx/query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json rename to .sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json index b88db42..5565a97 100644 --- a/.sqlx/query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json +++ b/.sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text, room_image_url\n ", + "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text, room_image_url, NULL::boolean as \"unread: _\"\n ", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "room_image_url", "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "unread: _", + "type_info": "Bool" } ], "parameters": { @@ -56,8 +61,9 @@ false, true, true, - true + true, + null ] }, - "hash": "dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1" + "hash": "7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a" } diff --git a/.sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json b/.sqlx/query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json similarity index 66% rename from .sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json rename to .sqlx/query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json index 908efce..836c6e1 100644 --- a/.sqlx/query-2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3.json +++ b/.sqlx/query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count\n FROM app_user u\n INNER JOIN user_relationship ur ON\n (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR\n (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED')\n ", + "query": "SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count,\n u.role\n FROM app_user u\n INNER JOIN user_relationship ur ON\n (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR\n (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED')\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "friends_count", "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" } ], "parameters": { @@ -45,8 +50,9 @@ true, false, true, + false, false ] }, - "hash": "2655a4f7a524b25d411b5509b7396623c4c86c3769fc349d59dab3b498aeeed3" + "hash": "b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436" } diff --git a/.sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json b/.sqlx/query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json similarity index 76% rename from .sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json rename to .sqlx/query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json index 02c3e84..f77351e 100644 --- a/.sqlx/query-bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56.json +++ b/.sqlx/query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n r_user.id,\n r_user.display_name,\n r_user.profile_picture,\n r_user.street_credits,\n r_user.description,\n r_user.friends_count\n FROM app_user r_user\n WHERE r_user.id = $1\n ", + "query": "SELECT\n r_user.id,\n r_user.display_name,\n r_user.profile_picture,\n r_user.street_credits,\n r_user.description,\n r_user.friends_count,\n r_user.role\n FROM app_user r_user\n WHERE r_user.id = $1\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "friends_count", "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" } ], "parameters": { @@ -45,8 +50,9 @@ true, false, true, + false, false ] }, - "hash": "bb17f3e5e740cddc97820e7c313bee11f5821b93c96aa5c89c278476eb865b56" + "hash": "caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd" } diff --git a/.sqlx/query-d0779c2f67e104b4387537026a1f6aba2f314490466d6994d515699cafd2ca2f.json b/.sqlx/query-d0779c2f67e104b4387537026a1f6aba2f314490466d6994d515699cafd2ca2f.json deleted file mode 100644 index 6f8ef68..0000000 --- a/.sqlx/query-d0779c2f67e104b4387537026a1f6aba2f314490466d6994d515699cafd2ca2f.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT display_name FROM app_user WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "display_name", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "d0779c2f67e104b4387537026a1f6aba2f314490466d6994d515699cafd2ca2f" -} diff --git a/.sqlx/query-b7fab8640b6c1bfd96b4349ebbaf6152fbe3a7d62a7c91fa094065eed27a15e9.json b/.sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json similarity index 83% rename from .sqlx/query-b7fab8640b6c1bfd96b4349ebbaf6152fbe3a7d62a7c91fa094065eed27a15e9.json rename to .sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json index 73a91db..70a0ab5 100644 --- a/.sqlx/query-b7fab8640b6c1bfd96b4349ebbaf6152fbe3a7d62a7c91fa094065eed27a15e9.json +++ b/.sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at,\n participants.last_message_read_at,\n participants.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1 AND participants.participant_state = 'Joined'\n ", + "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at,\n participants.last_message_read_at,\n participants.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1 AND participants.participant_state = 'Joined'\n ", "describe": { "columns": [ { @@ -48,5 +48,5 @@ false ] }, - "hash": "b7fab8640b6c1bfd96b4349ebbaf6152fbe3a7d62a7c91fa094065eed27a15e9" + "hash": "d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d" } diff --git a/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json b/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json new file mode 100644 index 0000000..6e7684e --- /dev/null +++ b/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n app_user.id,\n app_user.display_name,\n app_user.profile_picture,\n chat_room_participant.joined_at,\n chat_room_participant.last_message_read_at,\n chat_room_participant.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant\n JOIN app_user ON chat_room_participant.user_id = app_user.id\n WHERE chat_room_participant.room_id = $1 AND chat_room_participant.participant_state = 'Joined' AND chat_room_participant.user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "joined_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_message_read_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "membership_status: MembershipStatus", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false + ] + }, + "hash": "e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a" +} diff --git a/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json b/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json new file mode 100644 index 0000000..6f6d845 --- /dev/null +++ b/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO chat_room_participant (user_id, room_id, joined_at, participant_state)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, room_id)\n DO UPDATE SET joined_at = $3, participant_state = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346" +} diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 0194714..46c59e3 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -104,6 +104,10 @@ impl BroadcastChannel { error!("Unable to broadcast notification: {}", err); } } + } else { + if let Err(error) = self.notification_cache.add_notification_for_user(to_user, ¬ification).await { + error!("Failed to cache notification: {}", error); + }; } } diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index 23fde93..6043732 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::messaging::model::MessageDTO; -use crate::model::{ChatRoom}; +use crate::model::{ChatRoomDto, LastMessagePreviewText}; use crate::user_relationship::model::User; @@ -28,7 +28,7 @@ pub enum NotificationEvent { * Different chat messages, sent to all active users in a room */ #[serde(rename_all = "camelCase")] - ChatMessage {message: MessageDTO, display_value: String}, + ChatMessage {message: MessageDTO, room_preview_text: LastMessagePreviewText }, /** * A system message is a message not sent by a user, but by the system, whatever you want @@ -38,7 +38,7 @@ pub enum NotificationEvent { /** * Sending this event to a newly invited user */ - NewRoom {room: ChatRoom }, + NewRoom {room: ChatRoomDto }, /** * Sending this event to a user who has left a room @@ -49,7 +49,8 @@ pub enum NotificationEvent { /** * Sending this event to all users in a room where a member has left */ - RoomChangeEvent {message: MessageDTO} + #[serde(rename_all = "camelCase")] + RoomChangeEvent {message: MessageDTO, room_preview_text: LastMessagePreviewText} } diff --git a/src/cache/cache_cleanup.rs b/src/cache/cache_cleanup.rs index 760e2b0..7f015be 100644 --- a/src/cache/cache_cleanup.rs +++ b/src/cache/cache_cleanup.rs @@ -3,8 +3,7 @@ use redis::aio::MultiplexedConnection; use redis::{Client, RedisResult}; use redis::{AsyncCommands}; use tracing::{debug, error}; - -const MASTER_INDEX_SET: &str = "active_user_notification_indices"; +use crate::cache::util::MASTER_INDEX_SET; pub async fn periodic_cleanup_task(client: Client) { diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 4e9c68e..f01145e 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,2 +1,3 @@ pub mod redis_cache; -pub mod cache_cleanup; \ No newline at end of file +pub mod cache_cleanup; +mod util; \ No newline at end of file diff --git a/src/cache/redis_cache.rs b/src/cache/redis_cache.rs index 32a0870..938ef6d 100644 --- a/src/cache/redis_cache.rs +++ b/src/cache/redis_cache.rs @@ -3,8 +3,7 @@ use chrono::{DateTime, Utc}; use redis::{AsyncCommands, Client, ErrorKind, RedisError, RedisResult}; use uuid::Uuid; use crate::broadcast::Notification; - -const MASTER_INDEX_SET: &str = "active_user_notification_indices"; +use crate::cache::util::MASTER_INDEX_SET; #[async_trait] pub trait Cache: Send + Sync { diff --git a/src/cache/util.rs b/src/cache/util.rs new file mode 100644 index 0000000..f782a7a --- /dev/null +++ b/src/cache/util.rs @@ -0,0 +1,3 @@ + + +pub const MASTER_INDEX_SET: &str = "active_user_notification_indices"; diff --git a/src/messaging/message_service.rs b/src/messaging/message_service.rs index 738de83..36f4a3a 100644 --- a/src/messaging/message_service.rs +++ b/src/messaging/message_service.rs @@ -7,6 +7,7 @@ use crate::broadcast::NotificationEvent::ChatMessage; use crate::core::AppState; use crate::errors::{AppError}; use crate::messaging::model::{Message, MessageBody, MessageDTO, MsgType, NewMessage, NewMessageBody, NewReplyBody, RepliedMessageDetails, ReplyBody}; +use crate::model::LastMessagePreviewText; pub struct MessageService; @@ -41,11 +42,20 @@ impl MessageService { let msg = Message::new(message.chat_room_id, client_id, msg_body).map_err(|_err| { AppError::ProcessingError("Can't create chat message.".to_string()) })?; - + //save to nosql: state.message_repository.insert_data(msg.clone()).await?; + + let client_entity = state.room_repository.select_joined_user_by_id(&message.chat_room_id, &client_id).await?; + + let room_preview_text = MessageService::generate_room_preview_text(&message, client_entity.display_name); + let preview_str = serde_json::to_string(&room_preview_text).map_err(|err| { + AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) + })?; + + let mut tx = state.room_repository.start_transaction().await?; - let displayed = state.room_repository.update_last_room_message(&mut *tx, &message.chat_room_id, &msg.sender_id, MessageService::generate_room_preview_text(&message)).await?; + state.room_repository.update_last_room_message(&mut *tx, &message.chat_room_id, &preview_str).await?; state.room_repository.update_user_read_status(&mut *tx, &message.chat_room_id, &msg.sender_id).await?; tx.commit().await?; @@ -57,7 +67,7 @@ impl MessageService { BroadcastChannel::get().send_event_to_all( users, Notification { - body: ChatMessage {message: mapped_msg.clone(), display_value: displayed }, + body: ChatMessage {message: mapped_msg.clone(), room_preview_text }, created_at: Utc::now() } ).await; @@ -95,16 +105,16 @@ impl MessageService { Ok(new_body) } - fn generate_room_preview_text(msg: &NewMessage) -> String { + fn generate_room_preview_text(msg: &NewMessage, username: String) -> LastMessagePreviewText { match &msg.msg_body { NewMessageBody::Text(body) => { - format!(": {}", body.text) + LastMessagePreviewText::Text { sender_username: username, text: body.text.clone()} } - NewMessageBody::Media(_) => { - String::from(" hat etwas geteilt.") + NewMessageBody::Media(body) => { + LastMessagePreviewText::Media { sender_username: username, media_type: body.media_type.clone()} } - NewMessageBody::Reply(_) => { - String::from(" hat auf eine Nachricht geantwortet.") + NewMessageBody::Reply(body) => { + LastMessagePreviewText::Reply { sender_username: username, reply_text: body.reply_text.clone()} } } } diff --git a/src/model/room.rs b/src/model/room.rs index 503fd4a..c38c5df 100644 --- a/src/model/room.rs +++ b/src/model/room.rs @@ -1,9 +1,11 @@ +use crate::utils::truncate_and_serialize; use chrono::prelude::*; use serde::{Deserialize, Serialize}; use sqlx::Type; use uuid::Uuid; use crate::model::room_member::RoomMember; + #[derive(sqlx::FromRow, sqlx::Type, Debug)] pub struct ChatRoomEntity { pub id: Uuid, @@ -12,10 +14,79 @@ pub struct ChatRoomEntity { pub room_image_url: Option, pub created_at: DateTime, pub latest_message: Option>, - pub latest_message_preview_text: Option + pub latest_message_preview_text: Option, + pub unread: Option } -#[derive(Debug, Deserialize, Serialize, Clone)] +impl ChatRoomEntity { + + pub fn to_dto(&self) -> ChatRoomDto { + + let last_message = match self.latest_message_preview_text.as_ref() { + Some(text) => serde_json::from_str::(text).unwrap_or(LastMessagePreviewText::New), + None => LastMessagePreviewText::New + }; + + ChatRoomDto { + id: self.id, + room_type: self.room_type.clone(), + room_image_url: self.room_image_url.clone(), + room_name: self.room_name.clone(), + created_at: self.created_at, + latest_message: self.latest_message, + unread: self.unread, + latest_message_preview_text: last_message + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ChatRoomDto { + pub id: Uuid, + pub room_type: RoomType, + pub room_image_url: Option, + pub room_name: Option, + pub created_at: DateTime, + pub latest_message: Option>, + pub unread: Option, + pub latest_message_preview_text: LastMessagePreviewText +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatRoomWithUserDTO { + #[serde(flatten)] + pub room: ChatRoomDto, + pub users: Vec +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(tag = "type")] +pub enum LastMessagePreviewText { + Text { + sender_username: String, + #[serde(serialize_with = "truncate_and_serialize")] + text: String + }, + Media { + sender_username: String, + media_type: String + }, + Reply { + sender_username: String, + #[serde(serialize_with = "truncate_and_serialize")] + reply_text: String + }, + RoomChange { + sender_username: String, + room_change_type: RoomChangeType + }, + New +} + + +#[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct NewRoom { pub room_type: RoomType, @@ -23,6 +94,13 @@ pub struct NewRoom { pub invited_users: Vec } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub enum RoomChangeType { + LEAVE, + JOIN, + INVITE +} + #[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] #[sqlx(type_name = "room_type")] @@ -31,6 +109,7 @@ pub enum RoomType { Group } + impl RoomType { pub fn to_str(&self) -> &str { match self { @@ -48,29 +127,4 @@ impl RoomType { } -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ChatRoomWithUserDTO { - pub id: Uuid, - pub room_type: RoomType, - pub room_image_url: Option, - pub room_name: String, - pub created_at: DateTime, - pub latest_message: Option>, - pub unread: Option, - pub latest_message_preview_text: Option, - pub users: Vec -} -#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, sqlx::Type, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ChatRoom { - pub id: Uuid, - pub room_type: RoomType, - pub room_image_url: Option, - pub room_name: Option, - pub created_at: DateTime, - pub latest_message: Option>, - pub unread: Option, - pub latest_message_preview_text: Option -} \ No newline at end of file diff --git a/src/repository/room_repository.rs b/src/repository/room_repository.rs index 14c9f64..f02d365 100644 --- a/src/repository/room_repository.rs +++ b/src/repository/room_repository.rs @@ -2,7 +2,7 @@ use chrono::Utc; use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; use uuid::Uuid; use crate::model::room_member::{RoomMember, MembershipStatus}; -use crate::model::{ChatRoomEntity, ChatRoom, NewRoom, RoomType}; +use crate::model::{ChatRoomEntity, LastMessagePreviewText, NewRoom, RoomType}; #[derive(Clone)] pub struct RoomRepository { @@ -52,15 +52,32 @@ impl RoomRepository { participants.last_message_read_at, participants.participant_state AS "membership_status: MembershipStatus" FROM chat_room_participant AS participants - JOIN app_user AS users ON participants.user_id = users.id + JOIN app_user AS users ON participants.user_id = users.id WHERE participants.room_id = $1 AND participants.participant_state = 'Joined' "#, room_id).fetch_all(&self.pool).await?; Ok(users) } - pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { + pub async fn select_joined_user_by_id(&self, room_id: &Uuid, user_id: &Uuid) -> Result { + let users = sqlx::query_as!(RoomMember, + r#" + SELECT + app_user.id, + app_user.display_name, + app_user.profile_picture, + chat_room_participant.joined_at, + chat_room_participant.last_message_read_at, + chat_room_participant.participant_state AS "membership_status: MembershipStatus" + FROM chat_room_participant + JOIN app_user ON chat_room_participant.user_id = app_user.id + WHERE chat_room_participant.room_id = $1 AND chat_room_participant.participant_state = 'Joined' AND chat_room_participant.user_id = $2 + "#, room_id, user_id).fetch_one(&self.pool).await?; + Ok(users) + } + + pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { let rooms = sqlx::query_as!( - ChatRoom, + ChatRoomEntity, r#" WITH room_selection AS ( SELECT DISTINCT ON (room.id) @@ -101,9 +118,9 @@ impl RoomRepository { Ok(()) } - pub async fn find_specific_joined_room(&self, room_id: &Uuid, user_id: &Uuid) -> Result, sqlx::Error> { + pub async fn find_specific_joined_room(&self, room_id: &Uuid, user_id: &Uuid) -> Result, sqlx::Error> { let room = sqlx::query_as!( - ChatRoom, + ChatRoomEntity, r#" SELECT room.id, @@ -136,6 +153,13 @@ impl RoomRepository { } pub async fn insert_room(&self, new_room: NewRoom) -> Result { + + let preview_text = serde_json::to_string( + &LastMessagePreviewText::New + ).map_err(|_| { + sqlx::Error::InvalidArgument("Can't serialize room preview text".to_string()) + })?; + let room_entity = ChatRoomEntity { id: Uuid::new_v4(), room_type: new_room.room_type, @@ -143,7 +167,8 @@ impl RoomRepository { room_image_url: None, created_at: Utc::now(), latest_message: Option::from(Utc::now()), - latest_message_preview_text: Option::from(String::from("Chat wurde erstellt.")), + latest_message_preview_text: Option::from(preview_text), + unread: None }; //https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html @@ -154,7 +179,7 @@ impl RoomRepository { r#" INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text, room_image_url + RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text, room_image_url, NULL::boolean as "unread: _" "#, room_entity.id, room_entity.room_type.to_string(), @@ -183,7 +208,7 @@ impl RoomRepository { let room_details = sqlx::query_as!( ChatRoomEntity, r#" - SELECT id, room_type as "room_type: RoomType", room_name, created_at, latest_message, room_image_url, latest_message_preview_text + SELECT id, room_type as "room_type: RoomType", room_name, created_at, latest_message, room_image_url, latest_message_preview_text, NULL::boolean as "unread: _" FROM chat_room WHERE id = $1 "#, room_id).fetch_one(&self.pool).await?; @@ -219,10 +244,21 @@ impl RoomRepository { } } - pub async fn add_user_to_room(&self, user_id: &Uuid, room_id: &Uuid) -> Result { - let mut tx = self.pool.begin().await?; - sqlx::query!("INSERT INTO chat_room_participant (user_id, room_id, joined_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, room_id) DO UPDATE SET joined_at = $3, participant_state = 'Joined'", - user_id, room_id, Utc::now()).execute(&mut *tx).await?; + pub async fn add_user_to_room(&self, conn: &mut PgConnection, user_id: &Uuid, room_id: &Uuid) -> Result { + sqlx::query!( + r#" + INSERT INTO chat_room_participant (user_id, room_id, joined_at, participant_state) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, room_id) + DO UPDATE SET joined_at = $3, participant_state = $4 + "#, + user_id, + room_id, + Utc::now(), + MembershipStatus::Joined.to_string() + ) + .execute(&mut *conn) + .await?; let user = sqlx::query_as!(RoomMember, r#" @@ -236,10 +272,10 @@ impl RoomRepository { FROM chat_room_participant AS participants JOIN app_user AS users ON participants.user_id = users.id WHERE participants.user_id = $1 AND participants.room_id = $2 - "#, user_id, room_id).fetch_one(&mut *tx).await?; - let text = format!("{}{}", user.display_name, String::from(" ist in dem Chat beigetreten.")); //todo: think about a better latest msg logic - sqlx::query!("UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, text).execute(&mut *tx).await?; - tx.commit().await?; + "#, + user_id, + room_id + ).fetch_one(&mut *conn).await?; Ok(user) } @@ -269,16 +305,14 @@ impl RoomRepository { /// Like this: state.room_repository.get_connection().acquire().await.unwrap(); /// /// [workaround]: https://github.com/launchbadge/sqlx/issues/1015#issuecomment-767787777 - pub async fn update_last_room_message(&self, conn: &mut PgConnection, room_id: &Uuid, sender_id: &Uuid, preview_text: String) -> Result + pub async fn update_last_room_message(&self, conn: &mut PgConnection, room_id: &Uuid, preview_text: &String) -> Result<(), sqlx::Error> { - let name = sqlx::query!("SELECT display_name FROM app_user WHERE id = $1", sender_id).fetch_one(&mut *conn).await?; - let text = format!("{}{}", name.display_name, preview_text); sqlx::query!( "UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, - &text + preview_text ).execute(&mut *conn).await?; - Ok(text) + Ok(()) } pub async fn update_user_read_status<'e, E>(&self, exec: E, room_id: &Uuid, user_id: &Uuid) -> Result<(), sqlx::Error> @@ -294,10 +328,9 @@ impl RoomRepository { } - pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user: &RoomMember) -> Result<(), sqlx::Error> { - sqlx::query!("UPDATE chat_room_participant SET participant_state = 'Left' WHERE user_id = $1 AND room_id = $2", user.id, room_id).execute(&mut *conn).await?; - let text = format!("{}{}", user.display_name, String::from(" hat den Chat verlassen.")); //todo: think about a better latest msg logic - sqlx::query!("UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, text).execute(&mut *conn).await?; + pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user_id: &Uuid, preview_text: &String) -> Result<(), sqlx::Error> { + sqlx::query!("UPDATE chat_room_participant SET participant_state = 'Left' WHERE user_id = $1 AND room_id = $2", user_id, room_id).execute(&mut *conn).await?; + sqlx::query!("UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, preview_text).execute(&mut *conn).await?; Ok(()) } diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 32dc5ef..e5e7ccd 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -27,6 +27,7 @@ impl UserRepository { r_user.street_credits, r_user.description, r_user.friends_count, + r_user.role, user_relationship.user_a_id, user_relationship.user_b_id, user_relationship.state, @@ -53,7 +54,8 @@ impl UserRepository { r_user.profile_picture, r_user.street_credits, r_user.description, - r_user.friends_count + r_user.friends_count, + r_user.role FROM app_user r_user WHERE r_user.id = $1 "#, user_id @@ -70,6 +72,7 @@ impl UserRepository { r_user.street_credits, r_user.description, r_user.friends_count, + r_user.role, user_relationship.user_a_id, user_relationship.user_b_id, user_relationship.state, @@ -104,7 +107,8 @@ impl UserRepository { u.profile_picture, u.street_credits, u.description, - u.friends_count + u.friends_count, + u.role FROM app_user u INNER JOIN user_relationship ur ON (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR @@ -129,7 +133,8 @@ impl UserRepository { u.profile_picture, u.street_credits, u.description, - u.friends_count + u.friends_count, + u.role FROM app_user u INNER JOIN diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs index 67d2c96..fbab962 100644 --- a/src/rooms/handler.rs +++ b/src/rooms/handler.rs @@ -11,7 +11,7 @@ use crate::core::AppState; use crate::errors::{AppError}; use crate::keycloak::decode::KeycloakToken; use crate::messaging::model::MessageDTO; -use crate::model::{ChatRoom, ChatRoomWithUserDTO, NewRoom, RoomMember, RoomType, UploadResponse}; +use crate::model::{ChatRoomDto, ChatRoomWithUserDTO, NewRoom, RoomMember, RoomType, UploadResponse}; use crate::rooms::room_service::RoomService; use crate::rooms::timeline_service::TimelineService; use crate::user_relationship::user_service::UserService; @@ -54,7 +54,7 @@ pub async fn handle_get_users_in_room( pub async fn handle_get_joined_rooms( State(state): State>, Extension(token): Extension> -) -> Result>, AppError> { +) -> Result>, AppError> { let rooms = RoomService::get_joined_rooms(state, token.subject).await?; Ok(Json(rooms)) @@ -83,7 +83,7 @@ pub async fn handle_create_room( State(state): State>, Extension(token): Extension>, Json(mut payload): Json -) -> Result, AppError> { +) -> Result, AppError> { if !payload.invited_users.contains(&token.subject) { return Err(AppError::ValidationError("Sender ID is not in the list of invited users.".to_string())); @@ -123,7 +123,7 @@ pub async fn handle_get_room_list_item_by_id( Extension(token): Extension>, State(state): State>, Path(room_id): Path -) -> Result, AppError> { +) -> Result, AppError> { let room = RoomService::get_room_list_item_by_id(state, token.subject, room_id).await?; Ok(Json(room)) } diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 2f2fcde..9004bad 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -8,7 +8,7 @@ use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent}; use crate::core::AppState; use crate::errors::{AppError}; use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; -use crate::model::{ChatRoom, ChatRoomEntity, ChatRoomWithUserDTO, MembershipStatus, NewRoom, RoomMember, RoomType, UploadResponse}; +use crate::model::{ChatRoomDto, ChatRoomEntity, ChatRoomWithUserDTO, LastMessagePreviewText, MembershipStatus, NewRoom, RoomChangeType, RoomMember, RoomType, UploadResponse}; use crate::utils::crop_image_from_center; pub struct RoomService; @@ -20,9 +20,9 @@ impl RoomService { Ok(users) } - pub async fn get_joined_rooms(state: Arc, client_id: Uuid, ) -> Result, AppError> { + pub async fn get_joined_rooms(state: Arc, client_id: Uuid, ) -> Result, AppError> { let rooms = state.room_repository.get_joined_rooms(&client_id).await?; - Ok(rooms) + Ok(rooms.iter().map(|room| room.to_dto()).collect()) } pub async fn get_room_with_details(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { @@ -34,17 +34,7 @@ impl RoomService { match chat_room { Some(room) => { - let room_details = ChatRoomWithUserDTO { - id: room.id, - room_type: room.room_type, - room_name: room.room_name.unwrap_or(String::from("Unnamed Chat")), - room_image_url: room.room_image_url, - created_at: room.created_at, - latest_message: room.latest_message, - unread: room.unread, - latest_message_preview_text: room.latest_message_preview_text, - users: users, - }; + let room_details = ChatRoomWithUserDTO { room: room.to_dto(), users }; Ok(room_details) }, None => Err(AppError::NotFound("Room not found:".to_string())) @@ -57,7 +47,7 @@ impl RoomService { Ok(()) } - pub async fn create_room(state: Arc, client_id: Uuid, new_room: NewRoom) -> Result { + pub async fn create_room(state: Arc, client_id: Uuid, new_room: NewRoom) -> Result { let room_entity = state.room_repository.insert_room(new_room.clone()).await?; let users = new_room.invited_users; @@ -73,21 +63,21 @@ impl RoomService { state.room_repository.find_specific_joined_room(&room_entity.id, other_user) )?; - if let (Some(creator_dto), Some(participator_dto)) = (room_client, room_receiver) { + if let (Some(creator_room), Some(participator_room)) = (room_client, room_receiver) { let broadcast = BroadcastChannel::get(); broadcast.send_event(Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: participator_dto}, + body: crate::broadcast::NotificationEvent::NewRoom {room: participator_room.to_dto()}, created_at: Utc::now() }, other_user).await; broadcast.send_event(Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: creator_dto.clone()}, + body: crate::broadcast::NotificationEvent::NewRoom {room: creator_room.to_dto()}, created_at: Utc::now() }, &client_id).await; - Ok(creator_dto) + Ok(creator_room.to_dto()) } else { Err(AppError::ProcessingError("Newly created room is null.".to_string())) } @@ -100,19 +90,19 @@ impl RoomService { BroadcastChannel::get().send_event_to_all( users, Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: room.clone()}, + body: crate::broadcast::NotificationEvent::NewRoom {room: room.to_dto()}, created_at: Utc::now() } ).await; - Ok(room) + Ok(room.to_dto()) } } - pub async fn get_room_list_item_by_id(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { + pub async fn get_room_list_item_by_id(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { let room = state.room_repository.find_specific_joined_room(&room_id, &client_id).await?.ok_or_else(|| { AppError::NotFound("Room not found.".to_string()) })?; - Ok(room) + Ok(room.to_dto()) } pub async fn leave_room(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { @@ -144,16 +134,25 @@ impl RoomService { return Err(AppError::ValidationError("Private rooms doesn't allow invites!.".to_string())) }; //we have to check if the inviter is in the room and the invited user isn't! - let user_to_find = users.iter().find(|user| user.id == client_id); + users.iter().find(|user| user.id == client_id).ok_or_else(|| { + AppError::Blocked("Client is not in this room.".to_string()) + })?; + + let user_to_exclude = users.iter().find(|user| user.id == user_id); - match (user_to_find, user_to_exclude) { - (Some(_inviter), None) => {} //we have checked the invite rules and continue - _ => { - return Err(AppError::ValidationError("User conditions not met in this room.".to_string())) - } - }; + if user_to_exclude.is_some() { + return Err(AppError::BadRequest("User is already in this room.".to_string())) + } + //add him to the room - let user = state.room_repository.add_user_to_room(&user_id, &room_id).await?; + let mut tx = state.room_repository.start_transaction().await?; + let user = state.room_repository.add_user_to_room(&mut *tx, &user_id, &room_id).await?; + let preview_text = LastMessagePreviewText::RoomChange { sender_username: user.display_name.clone(), room_change_type: RoomChangeType::JOIN}; + let preview_str = serde_json::to_string(&preview_text).map_err(|_| { + AppError::ProcessingError("Can't serialize room preview text".to_string()) + })?; + state.room_repository.update_last_room_message(&mut *tx, &room_id, &preview_str).await?; + tx.commit().await?; //build room change message let message = Message::new(room_id, user.id, MessageBody::RoomChange(RoomChangeBody::UserJoined {related_user: user.clone()})) @@ -161,15 +160,16 @@ impl RoomService { //sending room change event to all previous users in the room let send_to: Vec = users.iter().map(|user| user.id).collect(); - save_message_and_broadcast(message, &state, send_to).await?; + save_room_change_message_and_broadcast(message, &state, send_to, preview_text).await?; //sending new room event to invited user let room_for_user = state.room_repository.find_specific_joined_room(&room_id, &user_id).await?.ok_or_else(|| { AppError::ProcessingError("Unable to find room for the invited user.".to_string()) })?; + BroadcastChannel::get().send_event( Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: room_for_user}, + body: crate::broadcast::NotificationEvent::NewRoom {room: room_for_user.to_dto()}, created_at: Utc::now() }, &user.id @@ -207,11 +207,10 @@ impl RoomService { } async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { - state.message_repository.clear_chat_room_messages(&room.id).await?; - let mut tx = state.room_repository.start_transaction().await?; state.room_repository.delete_room(&mut *tx, &room.id).await?; tx.commit().await?; + state.message_repository.clear_chat_room_messages(&room.id).await?; let send_to: Vec = users.iter().map(|user| user.id).collect(); BroadcastChannel::get().send_event_to_all( @@ -226,7 +225,13 @@ async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, u async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: RoomMember) -> Result<(), AppError> { let mut tx = state.room_repository.start_transaction().await?; - state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user).await?; + + let preview_message = LastMessagePreviewText::RoomChange { sender_username: leaving_user.display_name.clone(), room_change_type: RoomChangeType::LEAVE }; + let preview_text = serde_json::to_string(&preview_message).map_err(|err| { + AppError::ProcessingError(format!("Unable to serialize last message preview text: {}", err.to_string())) + })?; + + state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user.id, &preview_text).await?; leaving_user.membership_status = MembershipStatus::Left; if users.len() == 1 { //last user, delete this room now @@ -250,13 +255,15 @@ async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, use Ok(()) } else { //find and handle the leaving user + let message = Message::new(room.id, leaving_user.id, MessageBody::RoomChange(RoomChangeBody::UserLeft {related_user: leaving_user.clone()})) .map_err(|_err| AppError::ProcessingError("Unable to create room message".to_string()))?; let send_to: Vec = users.iter().map(|user| user.id).collect(); - save_message_and_broadcast(message, &state, send_to).await?; + save_room_change_message_and_broadcast(message, &state, send_to, preview_message).await?; tx.commit().await?; + //send ack to the leaving user BroadcastChannel::get().send_event( Notification { body: LeaveRoom {room_id: room.id}, @@ -269,7 +276,7 @@ async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, use } } -async fn save_message_and_broadcast(message: Message, state: &Arc, to_users: Vec) -> Result<(), AppError> { +async fn save_room_change_message_and_broadcast(message: Message, state: &Arc, to_users: Vec, preview_text: LastMessagePreviewText) -> Result<(), AppError> { state.message_repository.insert_data(message.clone()).await?; let mapped_msg = message.to_dto().map_err(|_| { @@ -277,7 +284,7 @@ async fn save_message_and_broadcast(message: Message, state: &Arc, to_ })?; let notification = Notification { - body: RoomChangeEvent{message: mapped_msg}, + body: RoomChangeEvent{message: mapped_msg, room_preview_text: preview_text}, created_at: Utc::now() }; diff --git a/src/user_relationship/model.rs b/src/user_relationship/model.rs index 962ce72..33fa361 100644 --- a/src/user_relationship/model.rs +++ b/src/user_relationship/model.rs @@ -229,7 +229,8 @@ pub struct User { pub street_credits: i64, pub profile_picture: Option, pub description: Option, - pub friends_count: i64 + pub friends_count: i64, + pub role: String } #[derive(Deserialize, Serialize, Default)] diff --git a/src/utils.rs b/src/utils.rs index ec96091..cbafd74 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,7 @@ use bytes::Bytes; use uuid::Uuid; use std::io::Cursor; use image::{GenericImageView, ImageError}; +use serde::Serializer; use crate::errors::{AppError}; use crate::core::AppState; @@ -50,4 +51,15 @@ pub fn crop_image_from_center( } } - +pub fn truncate_and_serialize(text: &String, serializer: S) -> Result +where + S: Serializer, +{ + if text.chars().count() > 50 { + let mut truncated = text.chars().take(40).collect::(); + truncated.push_str("..."); + serializer.serialize_str(&truncated) + } else { + serializer.serialize_str(text) + } +} From 9f7f12cd0d2d204cd849094f0eb44791239a0e44 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Mon, 10 Nov 2025 02:43:42 +0100 Subject: [PATCH 16/23] Implement Redis-based room membership caching and notification processing Introduces Redis-backed room membership caching to improve performance and reduce database calls. Adds event subscription handling for real-time processing of notifications via Redis channels. Updates related services, models, and configurations for seamless integration. --- .idea/workspace.xml | 52 +++++-------- Cargo.lock | 13 ++++ Cargo.toml | 3 +- src/broadcast/event_broadcast.rs | 14 ++-- src/cache/cache_cleanup.rs | 15 +--- src/cache/mod.rs | 3 +- src/cache/redis_cache.rs | 127 +++++++++++++++++++++++++++++-- src/cache/redis_subscriber.rs | 78 +++++++++++++++++++ src/core/app_state.rs | 7 +- src/messaging/message_service.rs | 29 +++++-- src/messaging/model.rs | 16 ++++ 11 files changed, 289 insertions(+), 68 deletions(-) create mode 100644 src/cache/redis_subscriber.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 31fc6da..bfb39f4 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,30 +12,18 @@ - - + + - - - - - - - - + + - + - - - - - - - + - + @@ -835,7 +823,6 @@ - @@ -860,7 +847,8 @@ - diff --git a/Cargo.lock b/Cargo.lock index f44913b..08f1dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.21.7" @@ -1958,6 +1967,7 @@ dependencies = [ "snafu", "sqlx", "sqlx-cli", + "thiserror 2.0.9", "time", "tokio", "tokio-stream", @@ -3147,10 +3157,13 @@ version = "1.0.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e3c1983f96fe1aa42d3e75d6eedc0374ba45f784fb86f130e2c8dac95817471" dependencies = [ + "arc-swap", "arcstr", + "backon", "bytes", "cfg-if", "combine", + "futures-channel", "futures-util", "itoa", "num-bigint", diff --git a/Cargo.toml b/Cargo.toml index b79736a..dce3161 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ image = { version = "0.25.8"} bytes = "1.10.1" base64 = "0.22.1" validator = { version = "0.20.0", features = ["derive"] } -redis = { version = "1.0.0-rc.3", features = ["tokio-comp"] } +redis = { version = "1.0.0-rc.3", features = ["tokio-comp", "connection-manager"] } #keycloak: @@ -45,6 +45,7 @@ try-again = "0.2.2" typed-builder = "0.23.0" url = "2.5.7" async-trait = "0.1.89" +thiserror = "2.0.9" [features] default = ["default-tls", "reqwest/charset", "reqwest/http2", "reqwest/macos-system-configuration"] diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 46c59e3..2f350c4 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -9,6 +9,7 @@ use tokio::time::interval; use crate::broadcast::Notification; use crate::cache::redis_cache::Cache; + static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new(); /// A `BroadcastChannel` struct is responsible for managing a collection of channels that are used @@ -31,10 +32,13 @@ static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new /// and can safely be used across multiple threads. Readers can access the map concurrently, /// while write operations are exclusive to ensure data integrity. pub struct BroadcastChannel { - channel: RwLock>>, - notification_cache: Arc + channel: UserConnectionMap, + cache: Arc } +type UserConnectionMap = RwLock>>; + + impl BroadcastChannel { pub async fn init(cache: Arc) { @@ -58,7 +62,7 @@ impl BroadcastChannel { fn new(cache: Arc) -> Self { BroadcastChannel { channel: RwLock::new(HashMap::new()), - notification_cache: cache + cache } } @@ -105,7 +109,7 @@ impl BroadcastChannel { } } } else { - if let Err(error) = self.notification_cache.add_notification_for_user(to_user, ¬ification).await { + if let Err(error) = self.cache.add_notification_for_user(to_user, ¬ification).await { error!("Failed to cache notification: {}", error); }; } @@ -124,7 +128,7 @@ impl BroadcastChannel { } } } else { - if let Err(error) = self.notification_cache.add_notification_for_user(&user_id, ¬ification).await { + if let Err(error) = self.cache.add_notification_for_user(&user_id, ¬ification).await { error!("Failed to cache notification: {}", error); }; } diff --git a/src/cache/cache_cleanup.rs b/src/cache/cache_cleanup.rs index 7f015be..8b83206 100644 --- a/src/cache/cache_cleanup.rs +++ b/src/cache/cache_cleanup.rs @@ -1,11 +1,11 @@ use std::time::Duration; -use redis::aio::MultiplexedConnection; -use redis::{Client, RedisResult}; +use redis::aio::{ConnectionManager}; +use redis::{RedisResult}; use redis::{AsyncCommands}; use tracing::{debug, error}; use crate::cache::util::MASTER_INDEX_SET; -pub async fn periodic_cleanup_task(client: Client) { +pub async fn periodic_cleanup_task(mut con: ConnectionManager) { let cleanup_interval = Duration::from_secs(3600); //atm each 1hr @@ -14,13 +14,6 @@ pub async fn periodic_cleanup_task(client: Client) { loop { tokio::time::sleep(cleanup_interval).await; debug!("Starting periodic cache cleanup..."); - let mut con = match client.get_multiplexed_async_connection().await { - Ok(c) => c, - Err(e) => { - error!("Can't connect to cache: {}", e); - continue; - } - }; // getting all user ids from the master index set let user_ids: Vec = match con.smembers(MASTER_INDEX_SET).await { @@ -41,7 +34,7 @@ pub async fn periodic_cleanup_task(client: Client) { } async fn cleanup_user_index( - con: &mut MultiplexedConnection, + con: &mut ConnectionManager, user_id: &str, ) -> RedisResult<()> { let sorted_set_key = format!("user_notifications:{}", user_id); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index f01145e..e7de13e 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,3 +1,4 @@ pub mod redis_cache; pub mod cache_cleanup; -mod util; \ No newline at end of file +mod util; +mod redis_subscriber; \ No newline at end of file diff --git a/src/cache/redis_cache.rs b/src/cache/redis_cache.rs index 938ef6d..6c0d140 100644 --- a/src/cache/redis_cache.rs +++ b/src/cache/redis_cache.rs @@ -1,28 +1,62 @@ +use std::collections::HashSet; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use redis::{AsyncCommands, Client, ErrorKind, RedisError, RedisResult}; +use log::info; +use redis::{AsyncTypedCommands, Client, ErrorKind, RedisError, RedisResult}; +use redis::{aio::ConnectionManagerConfig}; +use redis::aio::ConnectionManager; use uuid::Uuid; use crate::broadcast::Notification; +use crate::cache::cache_cleanup::periodic_cleanup_task; +use crate::cache::redis_subscriber::run_event_processor; use crate::cache::util::MASTER_INDEX_SET; #[async_trait] pub trait Cache: Send + Sync { + async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult>; async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()>; + async fn add_user_to_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()>; + async fn remove_user_from_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()>; + async fn get_user_for_room(&self, room_id: &Uuid) -> RedisResult>; + async fn set_user_for_room(&self, room_id: &Uuid, user_ids: &Vec) -> RedisResult<()>; + async fn publish_notification(&self, notification: Notification, channel_name: &String) -> RedisResult<()>; + } //docs: https://docs.rs/redis/latest/redis/ #[derive(Clone)] pub struct RedisCache { - pub client: Client, + client: Client, + pub connection: ConnectionManager +} + +impl RedisCache { + pub async fn new(redis_url: String) -> RedisResult { + let redis_client = Client::open(format!("{}/?protocol=3", redis_url))?; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let config = ConnectionManagerConfig::new() + .set_push_sender(tx) + .set_automatic_resubscription(); + + let mut connection_manager = redis_client.get_connection_manager_with_config(config).await?; + connection_manager.psubscribe("chat_room:*").await?; + + info!("Established connection to the redis cache."); + tokio::spawn(periodic_cleanup_task(connection_manager.clone())); + tokio::spawn(run_event_processor(rx, connection_manager.clone())); + Ok(Self { client: redis_client, connection: connection_manager }) + } } #[async_trait] impl Cache for RedisCache { + async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult> { - let mut con = self.client.get_multiplexed_async_connection().await?; + let mut con = self.connection.clone(); let sorted_set_key = format!("user_notifications:{}", user_id); let min_score = latest_ts.timestamp(); @@ -48,8 +82,7 @@ impl Cache for RedisCache { } async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()> { - - let mut con = self.client.get_multiplexed_async_connection().await?; + let mut con = self.connection.clone(); let notification_key = format!("notification:{}", Uuid::new_v4()); let notification_json = serde_json::to_string(notification) .map_err(|err| { @@ -80,6 +113,69 @@ impl Cache for RedisCache { Ok(()) } + async fn add_user_to_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { + let mut con = self.connection.clone(); + let key = format!("room_members:{}", room_id); + redis::cmd("SADD").arg(&key).arg(user_id.to_string()).exec_async(&mut con).await?; + Ok(()) + } + + async fn remove_user_from_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { + let mut con = self.connection.clone(); + let key = format!("room_members:{}", room_id); + redis::cmd("SREM").arg(&key).arg(user_id.to_string()).exec_async(&mut con).await?; + Ok(()) + } + + async fn get_user_for_room(&self, room_id: &Uuid) -> RedisResult> { + let mut conn = self.connection.clone(); + let key = format!("room_members:{}", room_id); + + let cached_user_ids: HashSet = conn.smembers(&key).await?; + if !cached_user_ids.is_empty() { + let user_uuids = cached_user_ids + .into_iter() + .filter_map(|id_str| Uuid::parse_str(&id_str).ok()) + .collect(); + return Ok(user_uuids); + } + Ok(vec![]) + } + + async fn set_user_for_room(&self, room_id: &Uuid, user_ids: &Vec) -> RedisResult<()> { + let mut conn = self.connection.clone(); + let key = format!("room_members:{}", room_id); + + if user_ids.is_empty() { + conn.del(&key).await?; + return Ok(()); + } + + let user_id_strs: Vec = user_ids.iter().map(Uuid::to_string).collect(); + + let mut pipe = redis::pipe(); + pipe.atomic() + .del(&key) + .sadd(&key, user_id_strs) + .ignore(); + + pipe.exec_async(&mut conn).await?; + Ok(()) + } + + async fn publish_notification(&self, notification: Notification, channel_name: &String) -> RedisResult<()> { + let mut con = self.connection.clone(); + let notification_json = serde_json::to_string(¬ification) + .map_err(|err| { + RedisError::from(( + ErrorKind::Parse, + "Failed to serialize notification to JSON", + err.to_string(), + )) + })?; + con.publish(channel_name, notification_json).await?; + Ok(()) + } } @@ -88,11 +184,32 @@ pub struct NoOpCache; #[async_trait] impl Cache for NoOpCache { + async fn get_notifications_for_user(&self, _user_id: &Uuid, _latest_ts: DateTime) -> RedisResult> { Ok(vec![]) } async fn add_notification_for_user(&self, _user_id: &Uuid, _notification: &Notification) -> RedisResult<()> { Ok(()) } + + async fn add_user_to_room_cache(&self, _user_id: &Uuid, _room_id: &Uuid) -> RedisResult<()> { + Ok(()) + } + + async fn remove_user_from_room_cache(&self, _user_id: &Uuid, _room_id: &Uuid) -> RedisResult<()> { + Ok(()) + } + + async fn get_user_for_room(&self, _room_id: &Uuid) -> RedisResult> { + Ok(vec![]) + } + + async fn set_user_for_room(&self, _room_id: &Uuid, _user_ids: &Vec) -> RedisResult<()> { + Ok(()) + } + + async fn publish_notification(&self, _notification: Notification, _channel_name: &String) -> RedisResult<()> { + Ok(()) + } } diff --git a/src/cache/redis_subscriber.rs b/src/cache/redis_subscriber.rs new file mode 100644 index 0000000..6a6fbfa --- /dev/null +++ b/src/cache/redis_subscriber.rs @@ -0,0 +1,78 @@ +use log::info; +use redis::{PushInfo, from_redis_value, AsyncTypedCommands, RedisError}; +use redis::aio::ConnectionManager; +use tokio::sync::mpsc::UnboundedReceiver; +use tracing::{error, warn}; +use uuid::Uuid; +use crate::broadcast::{BroadcastChannel, Notification, NotificationEvent}; +use thiserror::Error; + +#[derive(Debug, Error)] +enum ProcessorError { + + #[error("Ungültige Push-Nachrichten-Struktur")] + InvalidPushFormat, + + #[error("Deserialisierung der Nutzlast fehlgeschlagen: {0}")] + PayloadDeser(#[from] serde_json::Error), + + #[error("Redis-Fehler: {0}")] + Redis(#[from] RedisError), + + #[error("Redis-Fehler: {0}")] + RedisParsing(#[from] redis::ParsingError), +} + +pub async fn run_event_processor(mut rx: UnboundedReceiver, mut conn: ConnectionManager) { + + let _ = rx.recv().await; + info!("Redis Event-Processing active."); + + while let Some(push_message) = rx.recv().await { + info!("Received push message: {:?}", push_message); + let notification = match parse_push_message(push_message) { + Ok(message) => message, + Err(error) => { + warn!("Parsing of received push message failed. Ignoring. Push message: {:?}", error); + continue; + } + }; + + if let Err(e) = handle_notification(notification, &mut conn).await { + error!("Fehler bei der Verarbeitung der Notification: {}", e); + } + } +} + +fn parse_push_message(mut push_message: PushInfo) -> Result { + // `let-else` flacht die `if let`-Pyramide elegant ab. + let Some(payload_value) = push_message.data.pop() else { + return Err(ProcessorError::InvalidPushFormat); + }; + + let payload_str: String = from_redis_value(payload_value)?; + let notification: Notification = serde_json::from_str(&payload_str)?; + + Ok(notification) +} + +async fn handle_notification( + notification: Notification, + conn: &mut ConnectionManager, +) -> Result<(), ProcessorError> { + match ¬ification.body { + NotificationEvent::ChatMessage { message, .. } => { + let room_key = format!("room_members:{}", message.chat_room_id); + let member_ids: Vec = match conn.smembers(&room_key).await { + Ok(ids) => ids.into_iter().filter_map(|id_str| Uuid::parse_str(&id_str).ok()).collect(), + Err(e) => { + error!("Fehler beim Abrufen von Raum-Mitgliedern: {}", e); + return Ok(()) + } + }; + BroadcastChannel::get().send_event_to_all(member_ids, notification).await; + } + _ => {} + } + Ok(()) +} diff --git a/src/core/app_state.rs b/src/core/app_state.rs index 8eff778..dba6934 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -3,7 +3,6 @@ use log::info; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::task; use crate::broadcast::BroadcastChannel; -use crate::cache::cache_cleanup::periodic_cleanup_task; use crate::cache::redis_cache::{Cache, NoOpCache, RedisCache}; use crate::core::ISMConfig; use crate::database::{MessageDatabase, ObjectStorage}; @@ -48,11 +47,9 @@ impl AppState { let cache: Arc = match config.redis_cache_url.clone() { Some(url) => { - let client = redis::Client::open(url) + let cache = RedisCache::new(url).await .unwrap_or_else(|err| panic!("Unable to init redis cache: {}", err)); - info!("Established connection to the redis cache."); - tokio::spawn(periodic_cleanup_task(client.clone())); - Arc::new(RedisCache { client }) + Arc::new(cache) }, None => { info!("Redis is deactivated. Initializing NoOpCache..."); diff --git a/src/messaging/message_service.rs b/src/messaging/message_service.rs index 36f4a3a..51e3bef 100644 --- a/src/messaging/message_service.rs +++ b/src/messaging/message_service.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::sync::Arc; use chrono::Utc; use uuid::Uuid; -use crate::broadcast::{BroadcastChannel, Notification}; +use crate::broadcast::{Notification}; use crate::broadcast::NotificationEvent::ChatMessage; use crate::core::AppState; use crate::errors::{AppError}; @@ -18,8 +18,18 @@ impl MessageService { message: NewMessage, client_id: Uuid ) -> Result { - - let users = state.room_repository.select_room_participants_ids(&message.chat_room_id).await?; + + let mut users = state.cache.get_user_for_room(&message.chat_room_id).await.map_err(|err| { + AppError::ProcessingError(format!("Can't get user for room: {}", err.to_string())) + })?; + + if users.is_empty() { + users = state.room_repository.select_room_participants_ids(&message.chat_room_id).await?; + state.cache.set_user_for_room(&message.chat_room_id, &users).await.map_err(|err| { + AppError::ProcessingError(format!("Can't set user for room: {}", err.to_string())) + })?; + } + if !users.contains(&client_id) { return Err(AppError::Blocked("User has not access to this room.".to_string())); }; @@ -59,18 +69,21 @@ impl MessageService { state.room_repository.update_user_read_status(&mut *tx, &message.chat_room_id, &msg.sender_id).await?; tx.commit().await?; - + let mapped_msg = msg.to_dto().map_err(|err| { AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) })?; - BroadcastChannel::get().send_event_to_all( - users, + state.cache.publish_notification( Notification { body: ChatMessage {message: mapped_msg.clone(), room_preview_text }, created_at: Utc::now() - } - ).await; + }, + &format!("chat_room:{}", mapped_msg.chat_room_id) + ).await.map_err(|err| { + AppError::ProcessingError(format!("Can't publish notification: {}", err.to_string())) + })?; + Ok(mapped_msg) } diff --git a/src/messaging/model.rs b/src/messaging/model.rs index 78c02bc..0a29b00 100644 --- a/src/messaging/model.rs +++ b/src/messaging/model.rs @@ -6,6 +6,7 @@ use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; +use crate::errors::AppError; use crate::model::RoomMember; #[derive(Debug, Deserialize, Serialize, Clone)] @@ -75,6 +76,21 @@ pub struct MessageDTO { pub created_at: DateTime } +impl MessageDTO { + + pub fn from_json_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|err| { + AppError::ProcessingError(format!("Error parsing message: {}", err)) + }) + } + + pub fn json_str(&self) -> Result { + serde_json::to_string(self).map_err(|err| { + AppError::ProcessingError(format!("Error parsing message: {}", err)) + }) + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(untagged)] From c836673f21319eea9534e2155f884b34e2c94279 Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Mon, 10 Nov 2025 21:39:10 +0100 Subject: [PATCH 17/23] Refactor room and messaging services: simplify logic, enhance Redis caching integration, and optimize room queries Streamlines room creation, invitation, and messaging workflows. Improves synchronization between Redis caching and database operations. Updates room queries for better performance and consistency. Adds error handling for caching operations. --- .idea/workspace.xml | 56 +++++---- ...7b139df7e87f2143772245ce8c463e9ecaf59.json | 64 +++++++++++ ...e4e36859c99d08373def23f2b5ac5eab1143.json} | 4 +- ...cc50bba249137951f3c50ecbf58cdf18eb92.json} | 4 +- ...26cc1f7346c0fe3c9e70c16865bd5e31ad58a.json | 64 ----------- src/cache/mod.rs | 2 +- src/cache/redis_cache.rs | 32 +++--- src/cache/redis_subscriber.rs | 2 +- src/cache/util.rs | 21 +++- src/errors.rs | 40 ++++++- src/messaging/message_service.rs | 40 ++----- src/messaging/model.rs | 15 ++- src/repository/room_repository.rs | 106 ++++++++++-------- src/rooms/room_service.rs | 40 ++++--- 14 files changed, 286 insertions(+), 204 deletions(-) create mode 100644 .sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json rename .sqlx/{query-c567b02c0f9fa70d5d59534946841e7a64ee29c869e7877f85254e0b9c6d2996.json => query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json} (51%) rename .sqlx/{query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json => query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json} (92%) delete mode 100644 .sqlx/query-dc82158f427d843b16a86cce12626cc1f7346c0fe3c9e70c16865bd5e31ad58a.json diff --git a/.idea/workspace.xml b/.idea/workspace.xml index bfb39f4..8a1948d 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,18 +12,20 @@ - - + - - - - + + + - + + + + + - + @@ -408,15 +410,8 @@ - - - - - @@ -823,7 +826,6 @@ - @@ -848,7 +850,19 @@ - + + + + + file://$PROJECT_DIR$/src/cache/redis_cache.rs + 119 + + + diff --git a/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json b/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json new file mode 100644 index 0000000..3fdb074 --- /dev/null +++ b/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS p1\n JOIN\n chat_room AS room ON p1.room_id = room.id\n -- 3. To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n -- Only take the first match\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n p1.user_id = $1\n AND p1.participant_state = 'Joined'\n ORDER BY\n room.latest_message DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "room_type: RoomType", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "latest_message", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "latest_message_preview_text", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "room_name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "room_image_url", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "unread", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + null, + null, + null + ] + }, + "hash": "496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59" +} diff --git a/.sqlx/query-c567b02c0f9fa70d5d59534946841e7a64ee29c869e7877f85254e0b9c6d2996.json b/.sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json similarity index 51% rename from .sqlx/query-c567b02c0f9fa70d5d59534946841e7a64ee29c869e7877f85254e0b9c6d2996.json rename to .sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json index c8c9597..bc6ab41 100644 --- a/.sqlx/query-c567b02c0f9fa70d5d59534946841e7a64ee29c869e7877f85254e0b9c6d2996.json +++ b/.sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n CASE\n WHEN room.room_type = 'Single' THEN u.display_name\n ELSE room.room_name\n END AS room_name,\n CASE\n WHEN room.room_type = 'Single' THEN u.profile_picture\n ELSE room.room_image_url\n END AS room_image_url,\n CASE\n WHEN participants.last_message_read_at < room.latest_message THEN TRUE\n ELSE FALSE\n END AS unread\n FROM chat_room_participant AS participants\n JOIN chat_room AS room ON participants.room_id = room.id\n LEFT JOIN chat_room_participant crp ON crp.room_id = room.id AND crp.user_id != $1\n LEFT JOIN app_user u ON u.id = crp.user_id\n WHERE participants.user_id = $1 AND room.id = $2 AND participants.participant_state = 'Joined'\n ", + "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS participants\n JOIN\n chat_room AS room ON participants.room_id = room.id\n -- 3. To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n participants.user_id = $1\n AND room.id = $2\n AND participants.participant_state = 'Joined'\n ", "describe": { "columns": [ { @@ -61,5 +61,5 @@ null ] }, - "hash": "c567b02c0f9fa70d5d59534946841e7a64ee29c869e7877f85254e0b9c6d2996" + "hash": "7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143" } diff --git a/.sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json b/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json similarity index 92% rename from .sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json rename to .sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json index 5565a97..9f0a56e 100644 --- a/.sqlx/query-7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a.json +++ b/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text, room_image_url, NULL::boolean as \"unread: _\"\n ", + "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text, room_image_url, TRUE as \"unread: _\"\n ", "describe": { "columns": [ { @@ -65,5 +65,5 @@ null ] }, - "hash": "7f843c0d0d650e1c9d4b69ed379728e222517da03b7881fa33fa93e111c3880a" + "hash": "939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92" } diff --git a/.sqlx/query-dc82158f427d843b16a86cce12626cc1f7346c0fe3c9e70c16865bd5e31ad58a.json b/.sqlx/query-dc82158f427d843b16a86cce12626cc1f7346c0fe3c9e70c16865bd5e31ad58a.json deleted file mode 100644 index bb6b140..0000000 --- a/.sqlx/query-dc82158f427d843b16a86cce12626cc1f7346c0fe3c9e70c16865bd5e31ad58a.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH room_selection AS (\n SELECT DISTINCT ON (room.id)\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n CASE\n WHEN room.room_type = 'Single' THEN u.display_name\n ELSE room.room_name\n END AS room_name,\n CASE\n WHEN room.room_type = 'Single' THEN u.profile_picture\n ELSE room.room_image_url\n END AS room_image_url,\n CASE\n WHEN participants.last_message_read_at < room.latest_message THEN TRUE\n ELSE FALSE\n END AS unread\n FROM chat_room_participant AS participants\n JOIN chat_room AS room ON participants.room_id = room.id\n LEFT JOIN chat_room_participant crp ON crp.room_id = room.id AND crp.user_id != $1\n LEFT JOIN app_user u ON u.id = crp.user_id\n WHERE participants.user_id = $1 AND participants.participant_state = 'Joined'\n )\n SELECT * FROM room_selection\n ORDER BY latest_message DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "room_type: RoomType", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "latest_message", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "latest_message_preview_text", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "room_name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "room_image_url", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "unread", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - null, - null, - null - ] - }, - "hash": "dc82158f427d843b16a86cce12626cc1f7346c0fe3c9e70c16865bd5e31ad58a" -} diff --git a/src/cache/mod.rs b/src/cache/mod.rs index e7de13e..5f9d028 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,4 +1,4 @@ pub mod redis_cache; pub mod cache_cleanup; -mod util; +pub mod util; mod redis_subscriber; \ No newline at end of file diff --git a/src/cache/redis_cache.rs b/src/cache/redis_cache.rs index 6c0d140..96f4171 100644 --- a/src/cache/redis_cache.rs +++ b/src/cache/redis_cache.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::broadcast::Notification; use crate::cache::cache_cleanup::periodic_cleanup_task; use crate::cache::redis_subscriber::run_event_processor; -use crate::cache::util::MASTER_INDEX_SET; +use crate::cache::util::{CHAT_CHANNEL, MASTER_INDEX_SET, NOTIFICATION, ROOM_MEMBERS, USER_NOTIFICATIONS}; #[async_trait] pub trait Cache: Send + Sync { @@ -26,6 +26,7 @@ pub trait Cache: Send + Sync { //docs: https://docs.rs/redis/latest/redis/ #[derive(Clone)] +#[allow(unused)] pub struct RedisCache { client: Client, pub connection: ConnectionManager @@ -41,7 +42,7 @@ impl RedisCache { .set_automatic_resubscription(); let mut connection_manager = redis_client.get_connection_manager_with_config(config).await?; - connection_manager.psubscribe("chat_room:*").await?; + connection_manager.psubscribe(format!("{}*", CHAT_CHANNEL)).await?; //subscribe to all chat channels info!("Established connection to the redis cache."); tokio::spawn(periodic_cleanup_task(connection_manager.clone())); @@ -57,7 +58,7 @@ impl Cache for RedisCache { async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult> { let mut con = self.connection.clone(); - let sorted_set_key = format!("user_notifications:{}", user_id); + let sorted_set_key = format!("{}{}", USER_NOTIFICATIONS, user_id); let min_score = latest_ts.timestamp(); let notification_keys: Vec = con @@ -83,7 +84,7 @@ impl Cache for RedisCache { async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()> { let mut con = self.connection.clone(); - let notification_key = format!("notification:{}", Uuid::new_v4()); + let notification_key = format!("{}{}", NOTIFICATION, Uuid::new_v4()); let notification_json = serde_json::to_string(notification) .map_err(|err| { RedisError::from(( @@ -94,7 +95,7 @@ impl Cache for RedisCache { })?; let score = notification.created_at.timestamp(); - let sorted_set_key = format!("user_notifications:{}", user_id); + let sorted_set_key = format!("{}{}", USER_NOTIFICATIONS, user_id); let mut pipe = redis::pipe(); //like a atomic transaction pipe.atomic() @@ -115,21 +116,26 @@ impl Cache for RedisCache { async fn add_user_to_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { let mut con = self.connection.clone(); - let key = format!("room_members:{}", room_id); - redis::cmd("SADD").arg(&key).arg(user_id.to_string()).exec_async(&mut con).await?; + let key = format!("{}{}", ROOM_MEMBERS, room_id); + let exists: bool = con.exists(&key).await?; + + if !exists { //if the member list is empty, we don't need to add the user to it + return Ok(()) + } + con.sadd(&key, user_id.to_string()).await?; Ok(()) } async fn remove_user_from_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { let mut con = self.connection.clone(); - let key = format!("room_members:{}", room_id); - redis::cmd("SREM").arg(&key).arg(user_id.to_string()).exec_async(&mut con).await?; + let key = format!("{}{}", ROOM_MEMBERS, room_id); + con.srem(&key, user_id.to_string()).await?; Ok(()) } async fn get_user_for_room(&self, room_id: &Uuid) -> RedisResult> { let mut conn = self.connection.clone(); - let key = format!("room_members:{}", room_id); + let key = format!("{}{}", ROOM_MEMBERS, room_id); let cached_user_ids: HashSet = conn.smembers(&key).await?; if !cached_user_ids.is_empty() { @@ -142,9 +148,10 @@ impl Cache for RedisCache { Ok(vec![]) } + async fn set_user_for_room(&self, room_id: &Uuid, user_ids: &Vec) -> RedisResult<()> { let mut conn = self.connection.clone(); - let key = format!("room_members:{}", room_id); + let key = format!("{}{}", ROOM_MEMBERS, room_id); if user_ids.is_empty() { conn.del(&key).await?; @@ -156,8 +163,7 @@ impl Cache for RedisCache { let mut pipe = redis::pipe(); pipe.atomic() .del(&key) - .sadd(&key, user_id_strs) - .ignore(); + .sadd(&key, user_id_strs); pipe.exec_async(&mut conn).await?; Ok(()) diff --git a/src/cache/redis_subscriber.rs b/src/cache/redis_subscriber.rs index 6a6fbfa..fe22b56 100644 --- a/src/cache/redis_subscriber.rs +++ b/src/cache/redis_subscriber.rs @@ -45,7 +45,7 @@ pub async fn run_event_processor(mut rx: UnboundedReceiver, mut conn: } fn parse_push_message(mut push_message: PushInfo) -> Result { - // `let-else` flacht die `if let`-Pyramide elegant ab. + let Some(payload_value) = push_message.data.pop() else { return Err(ProcessorError::InvalidPushFormat); }; diff --git a/src/cache/util.rs b/src/cache/util.rs index f782a7a..57fce0a 100644 --- a/src/cache/util.rs +++ b/src/cache/util.rs @@ -1,3 +1,22 @@ - pub const MASTER_INDEX_SET: &str = "active_user_notification_indices"; + +/** + * Used to pub/sub room updates to the cache + */ +pub const CHAT_CHANNEL: &str = "chat_room:"; + +/** + * Used to pub/sub room updates to the cache + */ +pub const ROOM_MEMBERS: &str = "room_members:"; + +/** + * Short lived notification for a user + */ +pub const NOTIFICATION: &str = "notification:"; + +/** + * Set of notifications for a user + */ +pub const USER_NOTIFICATIONS: &str = "user_notifications:"; \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs index f20072d..412cef7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,10 +1,11 @@ use std::error::Error; -use std::fmt; +use std::{fmt}; use std::fmt::{Display, Formatter}; use axum::http::StatusCode; use axum::Json; use axum::response::{IntoResponse, Response}; use chrono::Utc; +use redis::RedisError; use serde::Serialize; use validator::ValidationErrors; @@ -100,7 +101,7 @@ impl IntoResponse for HttpError { timestamp: Utc::now().to_rfc3339(), status: status.as_u16(), error: status.canonical_reason().unwrap_or("Unknown Status").to_string(), - message: self.message.clone(), + message: self.message, path: None, error_code: self.error_code, }; @@ -129,6 +130,10 @@ pub enum AppError { BadRequest(String), + CacheError(RedisError), + + Generic(Box), + } impl fmt::Debug for AppError { @@ -141,6 +146,8 @@ impl fmt::Debug for AppError { Self::Blocked(msg) => write!(f, "Blocked: {}", msg), Self::S3Error(msg) => write!(f, "S3Error: {}", msg), Self::BadRequest(msg) => write!(f, "BadRequest: {}", msg), + Self::CacheError(err) => write!(f, "CacheError: {}", err), + Self::Generic(err) => write!(f, "An Generic unexpected error occurred: {}", err), } } } @@ -155,6 +162,8 @@ impl Display for AppError { AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), AppError::S3Error(msg) => write!(f, "S3Error: {}", msg), AppError::BadRequest(msg) => write!(f, "BadRequest: {}", msg), + AppError::CacheError(err) => write!(f, "CacheError: {}", err), + AppError::Generic(err) => write!(f, "An Generic unexpected error occurred: {}", err), } } } @@ -171,6 +180,12 @@ impl From for AppError { } } +impl From for AppError { + fn from(err: RedisError) -> AppError { + AppError::CacheError(err) + } +} + impl Error for AppError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { @@ -199,9 +214,9 @@ impl IntoResponse for AppError { AppError::DatabaseError(internal_err) => { tracing::error!("Database error: {:?}", internal_err); HttpError::new( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, ErrorCode::UnexpectedError, - "Internal Server Error. Try again." + "Internal Database Error. Try again." ) } AppError::ProcessingError(msg) => { @@ -221,7 +236,7 @@ impl IntoResponse for AppError { } AppError::S3Error(msg) => { HttpError::new( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, ErrorCode::UnexpectedError, msg ) @@ -233,6 +248,21 @@ impl IntoResponse for AppError { msg ) } + AppError::CacheError(err) => { + tracing::error!("Cache error: {:?}", err.to_string()); + HttpError::new( + StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::UnexpectedError, + "Internal Cache Error. Try again." + ) + } + AppError::Generic(err) => { + HttpError::new( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::UnexpectedError, + format!("An unexpected error occurred: {}", err) + ) + } }; http_error.into_response() diff --git a/src/messaging/message_service.rs b/src/messaging/message_service.rs index 51e3bef..7888b9e 100644 --- a/src/messaging/message_service.rs +++ b/src/messaging/message_service.rs @@ -1,9 +1,7 @@ use std::str::FromStr; use std::sync::Arc; -use chrono::Utc; use uuid::Uuid; -use crate::broadcast::{Notification}; -use crate::broadcast::NotificationEvent::ChatMessage; +use crate::broadcast::{BroadcastChannel}; use crate::core::AppState; use crate::errors::{AppError}; use crate::messaging::model::{Message, MessageBody, MessageDTO, MsgType, NewMessage, NewMessageBody, NewReplyBody, RepliedMessageDetails, ReplyBody}; @@ -19,19 +17,15 @@ impl MessageService { client_id: Uuid ) -> Result { - let mut users = state.cache.get_user_for_room(&message.chat_room_id).await.map_err(|err| { - AppError::ProcessingError(format!("Can't get user for room: {}", err.to_string())) - })?; + let mut users = state.cache.get_user_for_room(&message.chat_room_id).await?; if users.is_empty() { users = state.room_repository.select_room_participants_ids(&message.chat_room_id).await?; - state.cache.set_user_for_room(&message.chat_room_id, &users).await.map_err(|err| { - AppError::ProcessingError(format!("Can't set user for room: {}", err.to_string())) - })?; + state.cache.set_user_for_room(&message.chat_room_id, &users).await?; } if !users.contains(&client_id) { - return Err(AppError::Blocked("User has not access to this room.".to_string())); + return Err(AppError::Blocked("User hasn't access to this room.".to_string())); }; let msg_body = match message.msg_body.clone() { @@ -52,39 +46,29 @@ impl MessageService { let msg = Message::new(message.chat_room_id, client_id, msg_body).map_err(|_err| { AppError::ProcessingError("Can't create chat message.".to_string()) })?; - //save to nosql: - state.message_repository.insert_data(msg.clone()).await?; + //1. save message to nosql db: + state.message_repository.insert_data(msg.clone()).await?; + //2. generate new room preview text and save it to sql db: let client_entity = state.room_repository.select_joined_user_by_id(&message.chat_room_id, &client_id).await?; - let room_preview_text = MessageService::generate_room_preview_text(&message, client_entity.display_name); let preview_str = serde_json::to_string(&room_preview_text).map_err(|err| { AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) })?; - let mut tx = state.room_repository.start_transaction().await?; state.room_repository.update_last_room_message(&mut *tx, &message.chat_room_id, &preview_str).await?; state.room_repository.update_user_read_status(&mut *tx, &message.chat_room_id, &msg.sender_id).await?; tx.commit().await?; - - let mapped_msg = msg.to_dto().map_err(|err| { + //3. broadcast message to all room members: + let message_dto = msg.to_dto().map_err(|err| { AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) })?; - - state.cache.publish_notification( - Notification { - body: ChatMessage {message: mapped_msg.clone(), room_preview_text }, - created_at: Utc::now() - }, - &format!("chat_room:{}", mapped_msg.chat_room_id) - ).await.map_err(|err| { - AppError::ProcessingError(format!("Can't publish notification: {}", err.to_string())) - })?; - - Ok(mapped_msg) + let notification = msg.to_notification(room_preview_text)?; + BroadcastChannel::get().send_event_to_all(users, notification).await; + Ok(message_dto) } async fn create_reply_message(msg: &NewReplyBody, state: &Arc, room_id: &Uuid) -> Result> { diff --git a/src/messaging/model.rs b/src/messaging/model.rs index 0a29b00..6d65387 100644 --- a/src/messaging/model.rs +++ b/src/messaging/model.rs @@ -6,8 +6,10 @@ use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; +use crate::broadcast::Notification; +use crate::broadcast::NotificationEvent::ChatMessage; use crate::errors::AppError; -use crate::model::RoomMember; +use crate::model::{LastMessagePreviewText, RoomMember}; #[derive(Debug, Deserialize, Serialize, Clone)] pub enum MsgType { @@ -62,6 +64,17 @@ impl Message { }; Ok(message) } + + pub fn to_notification(&self, preview_text: LastMessagePreviewText) -> Result { + let mapped_msg = self.to_dto().map_err(|err| { + AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) + })?; + let notification = Notification { + body: ChatMessage {message: mapped_msg.clone(), room_preview_text: preview_text }, + created_at: Utc::now() + }; + Ok(notification) + } } diff --git a/src/repository/room_repository.rs b/src/repository/room_repository.rs index f02d365..4decac6 100644 --- a/src/repository/room_repository.rs +++ b/src/repository/room_repository.rs @@ -77,35 +77,40 @@ impl RoomRepository { pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { let rooms = sqlx::query_as!( - ChatRoomEntity, - r#" - WITH room_selection AS ( - SELECT DISTINCT ON (room.id) - room.id, - room.room_type AS "room_type: RoomType", - room.created_at, - room.latest_message, - room.latest_message_preview_text, - CASE - WHEN room.room_type = 'Single' THEN u.display_name - ELSE room.room_name - END AS room_name, - CASE - WHEN room.room_type = 'Single' THEN u.profile_picture - ELSE room.room_image_url - END AS room_image_url, - CASE - WHEN participants.last_message_read_at < room.latest_message THEN TRUE - ELSE FALSE - END AS unread - FROM chat_room_participant AS participants - JOIN chat_room AS room ON participants.room_id = room.id - LEFT JOIN chat_room_participant crp ON crp.room_id = room.id AND crp.user_id != $1 - LEFT JOIN app_user u ON u.id = crp.user_id - WHERE participants.user_id = $1 AND participants.participant_state = 'Joined' - ) - SELECT * FROM room_selection - ORDER BY latest_message DESC + ChatRoomEntity, + r#" + SELECT + room.id, + room.room_type AS "room_type: RoomType", + room.created_at, + room.latest_message, + room.latest_message_preview_text, + COALESCE(other_user.display_name, room.room_name) AS room_name, + COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, + COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread + FROM + chat_room_participant AS p1 + JOIN + chat_room AS room ON p1.room_id = room.id + -- 3. To find the other participant, only for single chat rooms! + LEFT JOIN LATERAL ( + SELECT + p2.user_id + FROM + chat_room_participant p2 + WHERE + p2.room_id = room.id AND p2.user_id != $1 + -- Only take the first match + LIMIT 1 + ) AS other_participant ON room.room_type = 'Single' + -- Only executed when the lateral join has matched something: + LEFT JOIN + app_user AS other_user ON other_user.id = other_participant.user_id + WHERE + p1.user_id = $1 + AND p1.participant_state = 'Joined' + ORDER BY + room.latest_message DESC "#, user_id ).fetch_all(&self.pool).await?; @@ -128,23 +133,30 @@ impl RoomRepository { room.created_at, room.latest_message, room.latest_message_preview_text, - CASE - WHEN room.room_type = 'Single' THEN u.display_name - ELSE room.room_name - END AS room_name, - CASE - WHEN room.room_type = 'Single' THEN u.profile_picture - ELSE room.room_image_url - END AS room_image_url, - CASE - WHEN participants.last_message_read_at < room.latest_message THEN TRUE - ELSE FALSE - END AS unread - FROM chat_room_participant AS participants - JOIN chat_room AS room ON participants.room_id = room.id - LEFT JOIN chat_room_participant crp ON crp.room_id = room.id AND crp.user_id != $1 - LEFT JOIN app_user u ON u.id = crp.user_id - WHERE participants.user_id = $1 AND room.id = $2 AND participants.participant_state = 'Joined' + COALESCE(other_user.display_name, room.room_name) AS room_name, + COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, + COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread + FROM + chat_room_participant AS participants + JOIN + chat_room AS room ON participants.room_id = room.id + -- 3. To find the other participant, only for single chat rooms! + LEFT JOIN LATERAL ( + SELECT + p2.user_id + FROM + chat_room_participant p2 + WHERE + p2.room_id = room.id AND p2.user_id != $1 + LIMIT 1 + ) AS other_participant ON room.room_type = 'Single' + -- Only executed when the lateral join has matched something: + LEFT JOIN + app_user AS other_user ON other_user.id = other_participant.user_id + WHERE + participants.user_id = $1 + AND room.id = $2 + AND participants.participant_state = 'Joined' "#, user_id, room_id @@ -179,7 +191,7 @@ impl RoomRepository { r#" INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text, room_image_url, NULL::boolean as "unread: _" + RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text, room_image_url, TRUE as "unread: _" "#, room_entity.id, room_entity.room_type.to_string(), diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 9004bad..98d8994 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -82,19 +82,15 @@ impl RoomService { Err(AppError::ProcessingError("Newly created room is null.".to_string())) } } else { //is group room - - let room = state.room_repository.find_specific_joined_room(&room_entity.id, &client_id).await?.ok_or_else(|| { - AppError::ProcessingError("Newly created room is null.".to_string()) - })?; - + let room_dto = room_entity.to_dto(); BroadcastChannel::get().send_event_to_all( users, Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: room.to_dto()}, + body: crate::broadcast::NotificationEvent::NewRoom {room: room_dto.clone()}, created_at: Utc::now() } ).await; - Ok(room.to_dto()) + Ok(room_dto) } } @@ -116,6 +112,7 @@ impl RoomService { return Err(AppError::Blocked("Client is not in this room.".to_string())) } }; + if room.room_type == RoomType::Single { //if someone leaves a single room, the whole room is getting wiped! handle_leave_private_room(state, room, users).await?; Ok(()) @@ -130,9 +127,11 @@ impl RoomService { state.room_repository.select_room(&room_id), state.room_repository.select_joined_user_in_room(&room_id) )?; + if room.room_type == RoomType::Single { return Err(AppError::ValidationError("Private rooms doesn't allow invites!.".to_string())) }; + //we have to check if the inviter is in the room and the invited user isn't! users.iter().find(|user| user.id == client_id).ok_or_else(|| { AppError::Blocked("Client is not in this room.".to_string()) @@ -144,7 +143,7 @@ impl RoomService { return Err(AppError::BadRequest("User is already in this room.".to_string())) } - //add him to the room + //1. add him to the room let mut tx = state.room_repository.start_transaction().await?; let user = state.room_repository.add_user_to_room(&mut *tx, &user_id, &room_id).await?; let preview_text = LastMessagePreviewText::RoomChange { sender_username: user.display_name.clone(), room_change_type: RoomChangeType::JOIN}; @@ -154,13 +153,13 @@ impl RoomService { state.room_repository.update_last_room_message(&mut *tx, &room_id, &preview_str).await?; tx.commit().await?; - //build room change message + //2. build room change message and send it to all previous users in the room let message = Message::new(room_id, user.id, MessageBody::RoomChange(RoomChangeBody::UserJoined {related_user: user.clone()})) .map_err(|_| AppError::ProcessingError("Unable to create room message".to_string()))?; - //sending room change event to all previous users in the room let send_to: Vec = users.iter().map(|user| user.id).collect(); save_room_change_message_and_broadcast(message, &state, send_to, preview_text).await?; + state.cache.add_user_to_room_cache(&user.id, &room_id).await?; //sending new room event to invited user let room_for_user = state.room_repository.find_specific_joined_room(&room_id, &user_id).await?.ok_or_else(|| { @@ -184,13 +183,12 @@ impl RoomService { } pub async fn set_room_image(state: Arc, room_id: Uuid, image_data: Bytes) -> Result { - let img = match crop_image_from_center(&image_data, 500, 500) { - Ok(img) => img, - Err(_err) => { - error!("Unable to crop image: {}", _err.to_string()); - return Err(AppError::ProcessingError("Unable to crop image.".to_string())) - } - }; + + let img = crop_image_from_center(&image_data, 500, 500).map_err(|err| { + error!("Unable to crop image: {}", err.to_string()); + AppError::ProcessingError("Unable to crop image.".to_string()) + })?; + let object_id = format!("rooms/{}", room_id); if let Err(err) = state.s3_bucket.insert_object(&object_id, img).await { error!("{}", err.to_string()); @@ -212,6 +210,8 @@ async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, u tx.commit().await?; state.message_repository.clear_chat_room_messages(&room.id).await?; + state.cache.set_user_for_room(&room.id, &vec![]).await?; + let send_to: Vec = users.iter().map(|user| user.id).collect(); BroadcastChannel::get().send_event_to_all( send_to, @@ -239,6 +239,8 @@ async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, use state.room_repository.delete_room(&mut *tx, &room.id).await?; tx.commit().await?; + state.cache.set_user_for_room(&room.id, &vec![]).await?; + BroadcastChannel::get().send_event( Notification { body: LeaveRoom {room_id: room.id}, @@ -259,10 +261,12 @@ async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, use let message = Message::new(room.id, leaving_user.id, MessageBody::RoomChange(RoomChangeBody::UserLeft {related_user: leaving_user.clone()})) .map_err(|_err| AppError::ProcessingError("Unable to create room message".to_string()))?; - let send_to: Vec = users.iter().map(|user| user.id).collect(); + let send_to: Vec = users.iter().filter(|user| user.id != leaving_user.id).map(|user| user.id).collect(); save_room_change_message_and_broadcast(message, &state, send_to, preview_message).await?; tx.commit().await?; + state.cache.remove_user_from_room_cache(&leaving_user.id, &room.id).await?; + //send ack to the leaving user BroadcastChannel::get().send_event( Notification { From ad8ca5a19bc9e2958946a0762ad1ac9e97405aec Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Mon, 10 Nov 2025 23:18:11 +0100 Subject: [PATCH 18/23] Add welcome message displaying app version and run mode Introduces a new `welcome` module to print a stylized app title, version, and run mode on startup. Updates main entry point to invoke the welcome message for better startup visibility. --- .env | 2 +- .idea/workspace.xml | 59 ++++++++++++++++----------------------------- Cargo.lock | 2 +- Cargo.toml | 2 +- src/lib.rs | 4 ++- src/main.rs | 7 +++--- src/welcome.rs | 23 ++++++++++++++++++ 7 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 src/welcome.rs diff --git a/.env b/.env index 965362d..2818f5e 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ DATABASE_URL=postgresql://postgres:meventure1234@localhost:32768/postgres -ISM_LOG_LEVEL=info +ISM_LOG_LEVEL=info \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 8a1948d..345d8d6 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,20 +12,14 @@ - + + + - - - - - - - - - - - - + + + + - + @@ -826,7 +820,6 @@ - @@ -851,18 +844,8 @@ - - - - - - file://$PROJECT_DIR$/src/cache/redis_cache.rs - 119 - - - + + diff --git a/Cargo.lock b/Cargo.lock index 08f1dca..1b8a8e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1937,7 +1937,7 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "ism" -version = "0.7.0" +version = "0.7.2" dependencies = [ "assertr", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index dce3161..dd9acae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ism" -version = "0.7.0" +version = "0.7.2" edition = "2024" [dependencies] diff --git a/src/lib.rs b/src/lib.rs index e3e74d9..f41afc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,4 +11,6 @@ pub mod messaging; pub mod utils; pub mod errors; pub mod router; -pub mod cache; \ No newline at end of file +pub mod cache; + +pub mod welcome; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index dd7634c..5d67592 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::EnvFilter; use ism::core::{AppState, ISMConfig}; use tracing_subscriber::filter::LevelFilter; use ism::router::init_router; +use ism::welcome::welcome; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads @@ -16,7 +17,7 @@ use ism::router::init_router; async fn main() { let config = init_configuration(); - + welcome(); //init the app state including database connections, broadcast channels, kafka etc. let app_state = AppState::new(config.clone()).await; @@ -73,8 +74,6 @@ fn init_configuration() -> ISMConfig { tracing_subscriber::fmt() .with_env_filter(filter) .init(); - - info!("Starting up ISM in {run_mode} mode."); - + config } \ No newline at end of file diff --git a/src/welcome.rs b/src/welcome.rs new file mode 100644 index 0000000..871f37f --- /dev/null +++ b/src/welcome.rs @@ -0,0 +1,23 @@ +use std::env; +use tracing::info; + +pub fn welcome() { + + let version = env!("CARGO_PKG_VERSION"); + let run_mode = env::var("ISM_MODE").unwrap_or_else(|_| "development".into()); + + let title = [ + r" ___ ____ __ __ ", + r" |_ _/ ___|| \/ | ", + r" | |\___ \| |\/| | ", + r" | | ___) | | | | ", + r" |__||____/|_| |_| ", + ]; + for line in title { + println!("{}", line); + } + println!(); + println!("Version: {} | Run-Mode: {}", version, run_mode); + println!(); + info!("Starting up ISM in {run_mode} mode."); +} \ No newline at end of file From 5992180dd3ae5c1dcc40076226691c6071d38f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Vossk=C3=BChler?= Date: Wed, 12 Nov 2025 02:52:08 +0100 Subject: [PATCH 19/23] change room img path --- .idea/workspace.xml | 93 ++++++++++++++++++--------------------- src/rooms/room_service.rs | 6 +-- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 345d8d6..b1d9757 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -13,13 +13,8 @@ - - - - - - + - { - "selectedUrlAndAccountId": { - "url": "https://github.com/JrTimha/ISM.git", - "accountId": "7e42ddce-4e81-4a2a-b5df-e0b46b7da343" + +}]]> @@ -71,47 +66,47 @@ - { + "keyToString": { + "Cargo.Run ISM.executor": "Run", + "Cargo.Run.executor": "Run", + "Cargo.sqlx.executor": "Run", + "Docker.Dockerfile runtime.executor": "Run", + "Docker.Dockerfile.executor": "Run", + "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", + "Docker.compose.yaml.console: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redis: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", + "Docker.compose.yaml: Compose Deployment.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.rust.reset.selective.auto.import": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "junie.onboarding.icon.badge.shown": "true", + "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", + "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", + "org.rust.first.attach.projects": "true", + "settings.editor.selected.configurable": "language.rust.cargo.check", + "to.speed.mode.migration.done": "true", + "vue.rearranger.settings.migration": "true" }, - "keyToStringList": { - "DatabaseDriversLRU": [ - "postgresql" + "keyToStringList": { + "DatabaseDriversLRU": [ + "postgresql" ] } -}]]> +} diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 98d8994..bea582e 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -189,7 +189,7 @@ impl RoomService { AppError::ProcessingError("Unable to crop image.".to_string()) })?; - let object_id = format!("rooms/{}", room_id); + let object_id = format!("{}/{}", state.env.object_db_config.bucket_name, room_id); if let Err(err) = state.s3_bucket.insert_object(&object_id, img).await { error!("{}", err.to_string()); return Err(AppError::S3Error("Unable save image in s3 bucket.".to_string())) @@ -250,8 +250,8 @@ async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, use ).await; //delete room image if it exists: - if let Some(url) = room.room_image_url { - state.s3_bucket.delete_object(&url).await + if let Some(_url) = room.room_image_url { + state.s3_bucket.delete_object(&room.id.to_string()).await .map_err(|_| AppError::ProcessingError("Unable to delete image from room".to_string()))?; } From 5ca1fb599685255f180386fc6cfa7fedd4f1668a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Vossk=C3=BChler?= Date: Wed, 12 Nov 2025 03:23:43 +0100 Subject: [PATCH 20/23] change room img path --- .idea/workspace.xml | 37 ++++++++++++++++++------------------- src/rooms/room_service.rs | 2 +- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index b1d9757..3a06c04 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,9 +12,8 @@ - + - - { + "selectedUrlAndAccountId": { + "url": "https://github.com/JrTimha/ISM.git", + "accountId": "a0cdf11b-08b2-491e-bf68-df1e7391a072" } -}]]> +} @@ -210,8 +209,8 @@ - + @@ -402,14 +401,6 @@ - - - @@ -815,7 +814,6 @@ - @@ -840,7 +838,8 @@ - diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index bea582e..eb98a3e 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -190,7 +190,7 @@ impl RoomService { })?; let object_id = format!("{}/{}", state.env.object_db_config.bucket_name, room_id); - if let Err(err) = state.s3_bucket.insert_object(&object_id, img).await { + if let Err(err) = state.s3_bucket.insert_object(&room_id.to_string(), img).await { error!("{}", err.to_string()); return Err(AppError::S3Error("Unable save image in s3 bucket.".to_string())) }; From 32229dbf35da8c3652b19dda7ad0bb14f69d857f Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Tue, 18 Nov 2025 02:11:34 +0100 Subject: [PATCH 21/23] Replace Kafka notification consumer with producer-based architecture Replaces the Kafka consumer with a producer for event-driven push notifications. Introduces `PushNotificationProducer` to handle outgoing notifications and integrates it with the broadcast system. Updates configuration, dependencies, and Docker setup to support the new architecture. --- .idea/workspace.xml | 132 +++++++------- Cargo.lock | 225 +++++++++++------------- Cargo.toml | 2 +- Dockerfile | 21 ++- default.config.toml | 7 +- development.config.toml | 6 +- src/broadcast/event_broadcast.rs | 53 +++--- src/core/app_state.rs | 21 +-- src/core/config.rs | 5 +- src/errors.rs | 6 + src/kafka/event_producer.rs | 84 +++++++++ src/kafka/mod.rs | 7 +- src/kafka/model.rs | 9 + src/kafka/notification_consumer.rs | 64 ------- src/kafka/push_notification_producer.rs | 35 ++++ 15 files changed, 370 insertions(+), 307 deletions(-) create mode 100644 src/kafka/event_producer.rs create mode 100644 src/kafka/model.rs delete mode 100644 src/kafka/notification_consumer.rs create mode 100644 src/kafka/push_notification_producer.rs diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 345d8d6..a9fc914 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -12,14 +12,22 @@ - - - + + + + - - + + + + + + + + + - { + "keyToString": { + "Cargo.Run ISM.executor": "Run", + "Cargo.Run.executor": "Run", + "Cargo.sqlx.executor": "Run", + "Docker.Dockerfile runtime.executor": "Run", + "Docker.Dockerfile.executor": "Run", + "Docker.compose.yaml.cassandra: Compose Deployment.executor": "Run", + "Docker.compose.yaml.console: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redis: Compose Deployment.executor": "Run", + "Docker.compose.yaml.redpanda-0: Compose Deployment.executor": "Run", + "Docker.compose.yaml: Compose Deployment.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.rust.reset.selective.auto.import": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultAutoModeForALLUsers.v1": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "junie.onboarding.icon.badge.shown": "true", + "last_opened_file_path": "/Users/timvosskuehler/RustroverProjects/ISM/src/repository", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", + "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "", + "org.rust.first.attach.projects": "true", + "settings.editor.selected.configurable": "language.rust.cargo.check", + "to.speed.mode.migration.done": "true", + "vue.rearranger.settings.migration": "true" }, - "keyToStringList": { - "DatabaseDriversLRU": [ - "postgresql" + "keyToStringList": { + "DatabaseDriversLRU": [ + "postgresql" ] } -}]]> +} @@ -186,12 +194,12 @@ - + @@ -405,15 +413,11 @@ - - - - -