diff --git a/.env b/.env index 48f9a16..2818f5e 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ DATABASE_URL=postgresql://postgres:meventure1234@localhost:32768/postgres -ISM_LOG_LEVEL=debug +ISM_LOG_LEVEL=info \ No newline at end of file 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/.gitignore b/.gitignore index 269cd83..3a8cabc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target .idea -/ISM-Rust/target 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..a9fc914 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -7,13 +7,27 @@ + - - + + + + + + + + + + + + + + + { "lastFilter": { @@ -54,7 +69,7 @@ - { "associatedIndex": 3 @@ -73,6 +88,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", @@ -80,9 +96,12 @@ "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", - "last_opened_file_path": "C:/Users/Tim/IdeaProjects/ISM/src/broadcast", + "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)", @@ -91,7 +110,8 @@ "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", + "to.speed.mode.migration.done": "true", "vue.rearranger.settings.migration": "true" }, "keyToStringList": { @@ -102,14 +122,15 @@ } + - - - - - + + + + + @@ -148,17 +169,6 @@ @@ -780,23 +832,6 @@ - - - - - - - - - - - - - - - - - @@ -805,7 +840,24 @@ - 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-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-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json b/.sqlx/query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json new file mode 100644 index 0000000..751422e --- /dev/null +++ b/.sqlx/query-2db5eb40e575583b817f371a4337c26a18cdb9878c8e395510cc3c0c14bcf0c6.json @@ -0,0 +1,59 @@ +{ + "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 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": [ + { + "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" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false, + false + ] + }, + "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-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-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-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-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-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-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json b/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json similarity index 85% rename from .sqlx/query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.json rename to .sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json index b88db42..9f0a56e 100644 --- a/.sqlx/query-dc6adb7aec669bb8f852086eda6599089a8e1477393e05847bde581ba2d8caa1.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\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": [ { @@ -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": "939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92" } 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-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json b/.sqlx/query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json new file mode 100644 index 0000000..836c6e1 --- /dev/null +++ b/.sqlx/query-b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436.json @@ -0,0 +1,58 @@ +{ + "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 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": [ + { + "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" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "b2c6a32f092f326e9188b6d913ca1b1e8355a11a27702dd6bbff8dfbdccfb436" +} 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-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json b/.sqlx/query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json new file mode 100644 index 0000000..f77351e --- /dev/null +++ b/.sqlx/query-caa396e4a482db60421acbd5e4ce69320be95c069369367c10f677d44d4152bd.json @@ -0,0 +1,58 @@ +{ + "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 r_user.role\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" + }, + { + "ordinal": 6, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false, + false + ] + }, + "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-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/.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/Cargo.lock b/Cargo.lock index 2813f50..bbfd5b6 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" @@ -124,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" @@ -149,10 +140,11 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "assertr" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb2563193472366adb562d419007007251b132685bccdbafc09dcd082b991a1" +checksum = "e65c749b72cf7cbc5ea70eabf96ff497a25d791ac180ccee924adf3c4a32e22b" dependencies = [ + "futures", "indoc", "num", ] @@ -168,33 +160,11 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[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", @@ -262,6 +232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -280,9 +251,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 +271,7 @@ dependencies = [ "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -315,9 +285,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 +296,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -348,18 +317,12 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.74" +name = "backon" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "fastrand", ] [[package]] @@ -386,7 +349,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 +372,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 +508,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]] @@ -646,6 +602,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" @@ -657,9 +627,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 +637,10 @@ dependencies = [ "pathdiff", "ron", "rust-ini", - "serde", + "serde-untagged", + "serde_core", "serde_json", - "toml 0.9.2", + "toml 0.9.8", "winnow", "yaml-rust2", ] @@ -738,15 +709,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.16" @@ -836,8 +798,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]] @@ -854,13 +826,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", ] @@ -1083,6 +1080,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 +1144,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" @@ -1207,9 +1235,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", ] @@ -1365,12 +1393,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" @@ -1389,7 +1411,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.7.0", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -1429,6 +1451,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.10.0" @@ -1596,7 +1624,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -1753,9 +1781,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", @@ -1774,9 +1802,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 +1812,7 @@ dependencies = [ "exr", "gif", "image-webp", + "moxcms", "num-traits", "png", "qoi", @@ -1824,13 +1853,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -1859,17 +1889,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" @@ -1894,11 +1913,13 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "ism" -version = "0.5.0" +version = "0.7.2" dependencies = [ "assertr", + "async-trait", "atomic-time", "axum", + "base64 0.22.1", "bytes", "chrono", "config", @@ -1911,8 +1932,9 @@ dependencies = [ "log", "minio", "nonempty", + "rdkafka", + "redis", "reqwest", - "samsa", "scylla", "serde", "serde-querystring", @@ -1921,6 +1943,7 @@ dependencies = [ "snafu", "sqlx", "sqlx-cli", + "thiserror 2.0.9", "time", "tokio", "tokio-stream", @@ -1932,6 +1955,7 @@ dependencies = [ "typed-builder", "url", "uuid", + "validator", ] [[package]] @@ -1991,12 +2015,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" @@ -2020,16 +2038,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", ] @@ -2165,7 +2185,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", ] @@ -2181,6 +2201,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2211,9 +2243,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 +2267,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 +2385,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" @@ -2412,21 +2454,11 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nombytes" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd7e08f12bf507c19d3ee3aed694b65f6df15123fd80bce21436b021b2f7471" -dependencies = [ - "bytes", - "nom", -] - [[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" @@ -2436,12 +2468,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]] @@ -2553,12 +2584,25 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.36.7" +name = "num_enum" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ - "memchr", + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] @@ -2573,7 +2617,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 +2665,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" @@ -2668,15 +2706,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", -] - [[package]] name = "pem" version = "3.0.4" @@ -2698,9 +2727,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" @@ -2782,17 +2811,17 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[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", @@ -2839,6 +2868,37 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[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" @@ -2867,6 +2927,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 +2963,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.0", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.9", "tokio", "tracing", @@ -2929,7 +2998,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -3082,13 +3151,70 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rdkafka" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1856d72dbbbea0d2a5b2eaf6af7fb3847ef2746e883b11781446a51dbc85c0" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "log", + "rdkafka-sys", + "serde", + "serde_derive", + "serde_json", + "slab", + "tokio", +] + +[[package]] +name = "rdkafka-sys" +version = "4.9.0+2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5230dca48bc354d718269f3e4353280e188b610f7af7e2fcf54b7a79d5802872" +dependencies = [ + "cmake", + "libc", + "libz-sys", + "num_enum", + "pkg-config", +] + +[[package]] +name = "redis" +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", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.1", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" 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 +3245,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 +3257,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" @@ -3157,9 +3268,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", @@ -3219,7 +3330,7 @@ dependencies = [ "getrandom 0.2.15", "libc", "spin", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3230,7 +3341,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", ] @@ -3255,42 +3366,16 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rsasl" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8b534a23662bb559c5c73213be63ecd6524e774d291f3618c2b04b723d184eb" -dependencies = [ - "base64 0.22.1", - "core2", - "digest", - "hmac", - "pbkdf2", - "rand 0.8.5", - "serde_json", - "sha1", - "sha2", - "stringprep", - "thiserror 1.0.69", -] - [[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 +3394,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 +3407,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]] @@ -3335,8 +3420,6 @@ version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -3345,15 +3428,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.10.1" @@ -3369,10 +3443,9 @@ version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3387,35 +3460,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" -[[package]] -name = "samsa" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c15dc7424e4bb741a412904d15c5a9d2244ddc1b184184fa3f4d373a825fc22" -dependencies = [ - "async-stream", - "async-trait", - "bytes", - "crc", - "flate2", - "futures", - "nom", - "nombytes", - "num-derive", - "num-traits", - "rand 0.8.5", - "rsasl", - "rustls-pemfile", - "rustls-pki-types", - "serde_json", - "tokio", - "tokio-rustls", - "tokio-stream", - "tracing", - "tracing-subscriber", - "webpki-roots 0.26.8", -] - [[package]] name = "schannel" version = "0.1.27" @@ -3457,9 +3501,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 +3517,7 @@ dependencies = [ "rand_pcg", "scylla-cql", "smallvec", - "socket2", + "socket2 0.5.10", "thiserror 2.0.9", "tokio", "tracing", @@ -3482,9 +3526,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,11 +3546,11 @@ 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", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.104", @@ -3518,7 +3562,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 +3590,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 +3608,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 +3642,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 +3674,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]] @@ -3628,19 +3695,18 @@ 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", "hex", "indexmap 1.9.3", - "indexmap 2.7.0", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.0.4", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3648,11 +3714,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", @@ -3669,6 +3735,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" @@ -3802,6 +3874,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" @@ -3876,7 +3958,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.2", "hashlink", - "indexmap 2.7.0", + "indexmap 2.12.0", "log", "memchr", "native-tls", @@ -3942,7 +4024,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3986,7 +4068,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "chrono", "crc", @@ -4126,7 +4208,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 +4318,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 +4397,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", @@ -4395,18 +4477,18 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[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 +4504,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]] @@ -4435,18 +4517,30 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.12.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + [[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 +4567,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 +4638,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 +4654,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" @@ -4594,24 +4682,30 @@ 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", "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" @@ -4663,6 +4757,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" @@ -4671,13 +4771,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]] @@ -4706,9 +4807,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", @@ -4727,6 +4828,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" @@ -4931,28 +5062,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 +5077,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 +5100,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 +5109,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 +5139,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 +5181,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 +5217,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 +5235,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 +5253,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 +5283,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 +5301,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 +5319,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 +5337,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 +5358,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 +5390,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..725d3e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,46 +1,51 @@ [package] name = "ism" -version = "0.5.0" +version = "0.7.2" 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" +rdkafka = { version = "0.38.0", features = ["cmake-build", "tokio"] } minio = { version = "0.3.0", features = ["default"] } -image = { version = "0.25.6"} +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", "connection-manager"] } #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" +serde_with = "3.15.1" 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" +async-trait = "0.1.89" +thiserror = "2.0.9" [features] default = ["default-tls", "reqwest/charset", "reqwest/http2", "reqwest/macos-system-configuration"] @@ -49,8 +54,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.4.2" +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..b0be9e0 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,23 +6,28 @@ COPY .sqlx ./.sqlx/ COPY Cargo.toml Cargo.lock ./ COPY src ./src -# Installiere OpenSSL-Entwicklungspakete ENV SQLX_OFFLINE=true -RUN apt-get update && apt-get install -y --no-install-recommends libssl-dev pkg-config +# Install package requirements +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libssl-dev \ + pkg-config \ + cmake -# Baue Abhängigkeiten -RUN cargo build --release --target x86_64-unknown-linux-gnu +# compile ism +RUN cargo build --release # Final Stage -FROM debian:bookworm-slim AS runtime +#https://github.com/GoogleContainerTools/distroless/blob/main/examples/rust/Dockerfile +FROM gcr.io/distroless/cc-debian12:nonroot WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends libssl-dev pkg-config ca-certificates COPY default.config.toml ./ +COPY --from=builder --chown=nonroot:nonroot /app/target/release/ism ./ -COPY --from=builder /app/target/x86_64-unknown-linux-gnu/release/ism ./ +USER nonroot ENV RUST_LOG=info ENV ISM_MODE=production 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/default.config.toml b/default.config.toml index aa58b5c..2782fe5 100644 --- a/default.config.toml +++ b/default.config.toml @@ -2,7 +2,9 @@ ism_url = "localhost" ism_port= 5403 log_level = "info" cors_origin = "http://localhost:4200" -use_kafka = false +use_kafka = true +push_notification_access_token="oiqhfriuhf" +push_notification_url="http://localhost:4200" [message_db_config] db_url = "localhost:9042" @@ -21,18 +23,17 @@ db_name = "postgres" [token_issuer] iss_host = "http://localhost:8180/" 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] bootstrap_host = "localhost" -bootstrap_port = 19192 -topic = "user-notification-events" +bootstrap_port = 19092 +topic = "push-notifications.requested.v1" client_id = "ism-1" partition = [0] consumer_group = "ism" \ No newline at end of file diff --git a/development.config.toml b/development.config.toml index f5ba1f8..5e886ba 100644 --- a/development.config.toml +++ b/development.config.toml @@ -1,6 +1,4 @@ ism_url = "127.0.0.1" ism_port= 7800 -use_kafka = false - -[token_issuer] -valid_admin_client = "api-client" \ No newline at end of file +use_kafka = true +redis_cache_url = "redis://127.0.0.1/" diff --git a/src/sql/import.sql b/sql/import.sql similarity index 93% rename from src/sql/import.sql rename to sql/import.sql index 7714d89..aa62f37 100644 --- a/src/sql/import.sql +++ b/sql/import.sql @@ -1,30 +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 @@ -49,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); diff --git a/src/api/errors.rs b/src/api/errors.rs deleted file mode 100644 index 46fe3ad..0000000 --- a/src/api/errors.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::fmt::Display; -use axum::http::StatusCode; -use axum::Json; -use axum::response::{IntoResponse, Response}; -use chrono::Utc; -use serde::Serialize; - -#[derive(Serialize)] -pub struct ErrorResponse { - timestamp: String, - status: u16, - error: String, - message: String, - path: Option, - #[serde(rename = "errorCode")] - error_code: ErrorCode, -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[allow(dead_code)] -pub enum ErrorCode { - // Authentication & Authorization - InsufficientPermissions, - - // User & Profile Errors - UserNotFound, - - // Content & Interaction Errors - RoomNotFound, - MessageNotFound, - InvalidContent, - FileProcessingError, - - // General API & Validation Errors - ValidationError, - ServiceUnavailable, - UnexpectedError, -} - -impl ErrorCode { - fn to_str(&self) -> String { - match self { - ErrorCode::UnexpectedError => "Server Error. Please try again later".to_string(), - ErrorCode::UserNotFound => "User not found.".to_string(), - ErrorCode::InsufficientPermissions => "You are not allowed to perform this action".to_string(), - _ => format!("{:?}", self), - } - } -} - -impl Display for ErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_str().to_owned()) - } -} - -#[derive(Debug)] -pub struct HttpError { - pub status_code: StatusCode, - pub error_code: ErrorCode, - pub message: String, -} - -impl HttpError { - - pub fn new(status_code: StatusCode, error_code: ErrorCode, message: impl Into) -> Self { - Self { - status_code, - error_code, - message: message.into(), - } - } - - pub fn bad_request(error_code: ErrorCode, message: impl Into) -> Self { - Self { - status_code: StatusCode::BAD_REQUEST, - error_code, - message: message.into(), - } - } - - -} - - -impl IntoResponse for HttpError { - fn into_response(self) -> Response { - - tracing::error!("An error occurred: status={}, code={:?}, msg='{}'", self.status_code, self.error_code, self.message); - - let status = self.status_code; - - let error_response = ErrorResponse { - timestamp: Utc::now().to_rfc3339(), - status: status.as_u16(), - error: status.canonical_reason().unwrap_or("Unknown Status").to_string(), - message: self.message.clone(), - path: None, - error_code: self.error_code, - }; - - (status, Json(error_response)).into_response() - } -} \ No newline at end of file diff --git a/src/api/messages.rs b/src/api/messages.rs deleted file mode 100644 index ae51b04..0000000 --- a/src/api/messages.rs +++ /dev/null @@ -1,146 +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::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; -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 { - 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, - Err(error) => { - error!("{}", error.to_string()); - return HttpError::bad_request(ErrorCode::UnexpectedError,"Can't fetch room participants.").into_response(); - } - }; - if !users.contains(&id) { - 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, id, 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/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/api/notifications.rs b/src/api/notifications.rs deleted file mode 100644 index 8100493..0000000 --- a/src/api/notifications.rs +++ /dev/null @@ -1,81 +0,0 @@ -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::api::errors::{ErrorCode, HttpError}; -use crate::api::utils::parse_uuid; -use crate::broadcast::{BroadcastChannel, SendNotification}; -use crate::core::AppState; -use crate::keycloak::decode::KeycloakToken; - - -pub async fn stream_server_events( - Extension(token): Extension> -) -> Sse>> { - - use futures::StreamExt; - let id = parse_uuid(&token.subject).unwrap(); - - let receiver = BroadcastChannel::get().subscribe_to_user_events(id.clone()).await; - - 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 - } - } - }); - Sse::new(stream).keep_alive( - axum::response::sse::KeepAlive::new() - .interval(Duration::from_secs(5)) - .text("keep-alive-text") - ) -} - -//todo: query latest events -pub async fn poll_for_new_notifications() -> impl IntoResponse { - //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() -} diff --git a/src/api/rooms.rs b/src/api/rooms.rs deleted file mode 100644 index 6998e07..0000000 --- a/src/api/rooms.rs +++ /dev/null @@ -1,490 +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::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::api::utils::{check_user_in_room, crop_image_from_center, parse_uuid}; -use crate::broadcast::{BroadcastChannel, Notification}; -use crate::broadcast::NotificationEvent::{LeaveRoom, NewRoom, RoomChangeEvent}; -use crate::core::AppState; - - -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 { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.get_joined_rooms(&id).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 { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &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.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, - 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 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 { - 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 { - let id = parse_uuid(&token.subject).unwrap(); - - if !payload.invited_users.contains(&id) { - 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 != id) { - 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, 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() - }, &id).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, &id).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 { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.find_specific_joined_room(&room_id, &id).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 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) - ); - 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 == id) { - 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: User) -> 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 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) - ); - 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 == 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 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 msg_to_dto(message) { - 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 { - let id = parse_uuid(&token.subject).unwrap(); - match state.room_repository.find_room_between_users(&id, ¶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 { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &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/api/timeline.rs b/src/api/timeline.rs deleted file mode 100644 index b33f160..0000000 --- a/src/api/timeline.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::str::FromStr; -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::api::errors::{ErrorCode, HttpError}; -use crate::api::utils::{check_user_in_room, parse_uuid}; -use crate::core::AppState; -use crate::keycloak::decode::KeycloakToken; -use crate::model::{Message, MessageDTO, MsgType}; - -#[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 { - let id = parse_uuid(&token.subject).unwrap(); - if let Err(err) = check_user_in_room(&state, &id, &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 msg_to_dto(message) { - 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() - } - } -} - -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/api/utils.rs b/src/api/utils.rs deleted file mode 100644 index c0947fc..0000000 --- a/src/api/utils.rs +++ /dev/null @@ -1,73 +0,0 @@ -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::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, - 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")) - } - }; - - if is_in { - Ok(()) - } else { - Err(HttpError::new(StatusCode::UNAUTHORIZED, ErrorCode::InsufficientPermissions, "Unable to check if user is in room")) - } -} - -pub fn crop_image_from_center( - data: &Bytes, - target_width: u32, - target_height: u32, -) -> 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.")) - } - }; - - let (original_width, original_height) = img.dimensions(); - - if original_width < target_width || original_height < target_height { - return Ok(data.clone()) - }; - - let x = (original_width - target_width) / 2; - let y = (original_height - target_height) / 2; - let cropped = img.crop_imm(x, y, target_width, target_height).to_rgb8(); - - let mut buffer = Cursor::new(Vec::new()); - match cropped.write_to(&mut buffer, image::ImageFormat::Jpeg){ - Ok(_) => { - Ok(Bytes::from(buffer.into_inner())) - }, - Err(err) => { - error!("{}", err); - Err(HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::FileProcessingError, "Image processing failed.")) - } - } -} diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 58a7c7a..960d61a 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -1,25 +1,49 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; use log::{debug, error, info}; use tokio::sync::{OnceCell, RwLock}; use uuid::Uuid; use tokio::sync::broadcast::{Sender, channel, Receiver}; -use tokio::time::interval; -use crate::broadcast::Notification; +use crate::broadcast::{Notification, NotificationEvent}; +use crate::cache::redis_cache::Cache; +use crate::kafka::{EventProducer, PushNotificationProducer}; 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>> + channel: UserConnectionMap, + cache: Arc, + push_notification_producer: PushNotificationProducer } +type UserConnectionMap = RwLock>>; + + impl BroadcastChannel { - pub async fn init() { + pub async fn init(cache: Arc, producer: PushNotificationProducer) { BROADCAST_INSTANCE.get_or_init(|| async { - let channel = Arc::new(BroadcastChannel::new()); - channel.clone().start_cleanup_task(); + let channel = Arc::new(BroadcastChannel::new(cache,producer)); + info!("BroadcastChannel initialized."); channel }).await; } @@ -33,35 +57,15 @@ impl BroadcastChannel { } } - fn new() -> Self { + fn new(cache: Arc, producer: PushNotificationProducer) -> Self { BroadcastChannel { channel: RwLock::new(HashMap::new()), + push_notification_producer: producer, + cache } } - - fn start_cleanup_task(self: Arc) { - tokio::spawn(async move { - let mut interval = interval(Duration::from_secs(60)); - loop { - interval.tick().await; - debug!("Starting broadcast garbage collection"); - self.cleanup_senders().await; - } - }); - } - - async fn cleanup_senders(&self) { - let mut lock = self.channel.write().await; - lock.retain(|&user_id, sender| { - if sender.receiver_count() > 0 { - true - } else { - info!("Removing stale sender for user {:?}", user_id); - false - } - }); - } - + + pub async fn subscribe_to_user_events(&self, user_id: Uuid) -> Receiver { let mut lock = self.channel.write().await; let sender = lock.entry(user_id) @@ -80,11 +84,17 @@ impl BroadcastChannel { error!("Unable to broadcast notification: {}", err); } } + } else { + if let Err(error) = self.cache.add_notification_for_user(to_user, ¬ification).await { + error!("Failed to cache notification: {}", error); + }; + self.send_undeliverable_notifications(notification, vec![to_user.clone()]).await; } } pub async fn send_event_to_all(&self, user_ids: Vec, notification: Notification) { let lock = self.channel.read().await; + let mut not_deliverable: Vec = Vec::new(); for user_id in user_ids { if let Some(sender) = lock.get(&user_id) { match sender.send(notification.clone()) { @@ -95,13 +105,44 @@ impl BroadcastChannel { error!("Unable to broadcast notification: {}", err); } } + } else { + if let Err(error) = self.cache.add_notification_for_user(&user_id, ¬ification).await { + error!("Failed to cache notification: {}", error); + }; + not_deliverable.push(user_id); + } + } + if not_deliverable.len() > 0 { + self.send_undeliverable_notifications(notification, not_deliverable).await; + } + } + + async fn send_undeliverable_notifications(&self, notification: Notification, to_user: Vec) { + let should_send = matches!( //Only sends push notifications for these notification types, add more if needed + notification.body, + NotificationEvent::ChatMessage { .. } | + NotificationEvent::FriendRequestReceived { .. } | + NotificationEvent::NewRoom { .. } + ); + + if should_send { + if let Err(error) = self.push_notification_producer.send_notification(notification, to_user).await { + error!("Failed to send push notification: {}", error); } } } - 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/broadcast/notification.rs b/src/broadcast/notification.rs index 9090be0..6043732 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::{ChatRoomListItemDTO, MessageDTO}; +use crate::messaging::model::MessageDTO; +use crate::model::{ChatRoomDto, LastMessagePreviewText}; +use crate::user_relationship::model::User; + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -16,16 +19,16 @@ 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 */ #[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 @@ -35,7 +38,7 @@ pub enum NotificationEvent { /** * Sending this event to a newly invited user */ - NewRoom {room: ChatRoomListItemDTO}, + NewRoom {room: ChatRoomDto }, /** * Sending this event to a user who has left a room @@ -46,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 new file mode 100644 index 0000000..8b83206 --- /dev/null +++ b/src/cache/cache_cleanup.rs @@ -0,0 +1,77 @@ +use std::time::Duration; +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(mut con: ConnectionManager) { + + 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..."); + + // 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 ConnectionManager, + 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..5f9d028 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,4 @@ +pub mod redis_cache; +pub mod cache_cleanup; +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 new file mode 100644 index 0000000..96f4171 --- /dev/null +++ b/src/cache/redis_cache.rs @@ -0,0 +1,221 @@ +use std::collections::HashSet; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +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::{CHAT_CHANNEL, MASTER_INDEX_SET, NOTIFICATION, ROOM_MEMBERS, USER_NOTIFICATIONS}; + +#[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)] +#[allow(unused)] +pub struct RedisCache { + 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(format!("{}*", CHAT_CHANNEL)).await?; //subscribe to all chat channels + + 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.connection.clone(); + 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.connection.clone(); + 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(()) + } + + 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); + 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); + 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 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); + + 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(()) + } +} + + +//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(()) + } + + 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..fe22b56 --- /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 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/cache/util.rs b/src/cache/util.rs new file mode 100644 index 0000000..57fce0a --- /dev/null +++ b/src/cache/util.rs @@ -0,0 +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/core/app_state.rs b/src/core/app_state.rs index 029bd24..67ef7e8 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -1,10 +1,78 @@ +use std::sync::Arc; +use log::info; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use crate::broadcast::BroadcastChannel; +use crate::cache::redis_cache::{Cache, NoOpCache, RedisCache}; use crate::core::ISMConfig; -use crate::database::{MessageDatabase, ObjectDatabase, RoomDatabase}; +use crate::database::{MessageDatabase, ObjectStorage}; +use crate::kafka::{PushNotificationProducer}; +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 cache: Arc, + 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(20) + .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); + } + }; + + let cache: Arc = match config.redis_cache_url.clone() { + Some(url) => { + let cache = RedisCache::new(url).await + .unwrap_or_else(|err| panic!("Unable to init redis cache: {}", err)); + Arc::new(cache) + }, + None => { + info!("Redis is deactivated. Initializing NoOpCache..."); + Arc::new(NoOpCache) + } + }; + + //init broadcaster channel + BroadcastChannel::init( + cache.clone(), + PushNotificationProducer::new(config.use_kafka, config.kafka_config.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, + cache: cache + }; + + state + } } \ No newline at end of file diff --git a/src/core/config.rs b/src/core/config.rs index 269da96..6bc8106 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -10,18 +10,21 @@ pub struct ISMConfig { pub use_kafka: bool, pub log_level: String, pub cors_origin: String, + pub push_notification_url: Option, + pub push_notification_access_token: Option, + pub redis_cache_url: Option, 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 } @@ -46,8 +49,7 @@ pub struct UserDbConfig { #[derive(Deserialize, Debug, Clone)] pub struct TokenIssuer { pub iss_host: String, - pub iss_realm: String, - pub valid_admin_client: Option + pub iss_realm: String } #[derive(Deserialize, Debug, Clone)] @@ -68,8 +70,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/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 39cf265..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, ObjectDbConfig, TokenIssuer, KafkaConfig}; -pub use app_state::*; \ No newline at end of file +pub use config::{ISMConfig, UserDbConfig, MessageDbConfig, ObjectStorageConfig, TokenIssuer, KafkaConfig}; +pub use app_state::*; diff --git a/src/database/message_database.rs b/src/database/message_database.rs index 30a3dcb..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}; @@ -9,7 +10,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 { @@ -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::()?; @@ -75,12 +76,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/errors.rs b/src/errors.rs new file mode 100644 index 0000000..4bbf42a --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,278 @@ +use std::error::Error; +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; + +#[derive(Serialize)] +pub struct ErrorResponse { + timestamp: String, + status: u16, + error: String, + message: String, + path: Option, + #[serde(rename = "errorCode")] + error_code: ErrorCode, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[allow(dead_code)] +pub enum ErrorCode { + // Authentication & Authorization + InsufficientPermissions, + + // User & Profile Errors + UserNotFound, + + // Content & Interaction Errors + RoomNotFound, + MessageNotFound, + InvalidContent, + FileProcessingError, + + ContentNotFound, + + // General API & Validation Errors + ValidationError, + ServiceUnavailable, + UnexpectedError, +} + +impl ErrorCode { + fn to_str(&self) -> String { + match self { + ErrorCode::UnexpectedError => "Server Error. Please try again later".to_string(), + ErrorCode::UserNotFound => "User not found.".to_string(), + ErrorCode::InsufficientPermissions => "You are not allowed to perform this action".to_string(), + _ => format!("{:?}", self), + } + } +} + +impl Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_str().to_owned()) + } +} + +#[derive(Debug)] +pub struct HttpError { + pub status_code: StatusCode, + pub error_code: ErrorCode, + pub message: String, +} + +impl HttpError { + + pub fn new(status_code: StatusCode, error_code: ErrorCode, message: impl Into) -> Self { + Self { + status_code, + error_code, + message: message.into(), + } + } + + pub fn bad_request(error_code: ErrorCode, message: impl Into) -> Self { + Self { + status_code: StatusCode::BAD_REQUEST, + error_code, + message: message.into(), + } + } + + +} + + +impl IntoResponse for HttpError { + fn into_response(self) -> Response { + + tracing::error!("An error occurred: status={}, code={:?}, msg='{}'", self.status_code, self.error_code, self.message); + + let status = self.status_code; + + let error_response = ErrorResponse { + timestamp: Utc::now().to_rfc3339(), + status: status.as_u16(), + error: status.canonical_reason().unwrap_or("Unknown Status").to_string(), + message: self.message, + path: None, + error_code: self.error_code, + }; + + (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), + + S3Error(String), + + BadRequest(String), + + CacheError(RedisError), + + Generic(Box), + +} + +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), + 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), + } + } +} + +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), + 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), + } + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> AppError { + AppError::DatabaseError(Box::new(err)) + } +} + +impl From for AppError { + fn from(err: scylla::errors::ExecutionError) -> AppError { + AppError::DatabaseError(Box::new(err)) + } +} + +impl From for AppError { + fn from(err: RedisError) -> AppError { + AppError::CacheError(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> AppError { + AppError::ProcessingError(err.to_string()) + } +} + +impl Error for AppError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + AppError::DatabaseError(err) => Some(err.as_ref()), + _ => None, + } + } +} + +impl From for AppError { + fn from(errors: ValidationErrors) -> Self { + AppError::BadRequest(errors.to_string()) + } +} + +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::SERVICE_UNAVAILABLE, + ErrorCode::UnexpectedError, + "Internal Database Error. Try again." + ) + } + 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::UNAUTHORIZED, + ErrorCode::InsufficientPermissions, + msg + ) + } + AppError::S3Error(msg) => { + HttpError::new( + StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::UnexpectedError, + msg + ) + } + AppError::BadRequest(msg) => { + HttpError::new( + StatusCode::BAD_REQUEST, + ErrorCode::ValidationError, + 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() + } +} + +pub type AppResponse = Result; \ No newline at end of file diff --git a/src/kafka/event_producer.rs b/src/kafka/event_producer.rs new file mode 100644 index 0000000..c716732 --- /dev/null +++ b/src/kafka/event_producer.rs @@ -0,0 +1,84 @@ +use std::time::Duration; +use async_trait::async_trait; +use rdkafka::{ClientConfig}; +use rdkafka::message::{Header, OwnedHeaders}; +use rdkafka::producer::{FutureProducer, FutureRecord}; +use tracing::{debug, error}; +use uuid::Uuid; +use crate::broadcast::Notification; +use crate::core::KafkaConfig; +use crate::errors::AppError; +use crate::kafka::model::PushNotification; + +#[async_trait] +pub trait EventProducer: Send + Sync { + async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError>; +} + +pub struct KafkaEventProducer { + producer: FutureProducer, + config: KafkaConfig, +} + +impl KafkaEventProducer { + pub fn new(config: KafkaConfig) -> Self { + let server = format!("{}:{}", config.bootstrap_host, config.bootstrap_port); + let producer = ClientConfig::new() + .set("bootstrap.servers", &server) + .set("enable.idempotence", "true") + .create() + .expect("Producer creation failed"); + Self { producer, config } + } +} + +#[async_trait] +impl EventProducer for KafkaEventProducer { + + + async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError> { + let payload = serde_json::to_string(&PushNotification{to_user, notification}) + .map_err(|e| AppError::from(e))?; + let response = self.producer.send( + FutureRecord::<(), String>::to(&self.config.topic) + .payload(&payload) + .headers( + OwnedHeaders::new() + .insert(Header { + key: "__TypeId__", + value: Some("com.meventure.api.notifications.model.UndeliveredMessage".as_bytes()), + }) + .insert(Header { + key: "contentType", + value: Some("application/json".as_bytes()), + }) + ), + Duration::from_secs(0), + ).await; + match response { + Ok(delivery) => { + debug!("Delivery result: {:?}", delivery); + Ok(()) + } + Err((kafka_error, _)) => { + error!("Kafka event delivery failed: {:?}", kafka_error.to_string()); + Err(AppError::ProcessingError("Unable to send push notification".to_string())) + } + } + } +} + +pub struct LogEventProducer; + +impl LogEventProducer { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl EventProducer for LogEventProducer { + async fn send_notification(&self, _notification: Notification, _to_user: Vec) -> Result<(), AppError> { + Ok(()) + } +} \ No newline at end of file diff --git a/src/kafka/mod.rs b/src/kafka/mod.rs index 1da19c8..de101b3 100644 --- a/src/kafka/mod.rs +++ b/src/kafka/mod.rs @@ -1,3 +1,6 @@ -mod notification_consumer; +mod event_producer; +mod model; +mod push_notification_producer; -pub use notification_consumer::{start_consumer}; \ No newline at end of file +pub use event_producer::{EventProducer}; +pub use push_notification_producer::PushNotificationProducer; \ No newline at end of file diff --git a/src/kafka/model.rs b/src/kafka/model.rs new file mode 100644 index 0000000..8085e51 --- /dev/null +++ b/src/kafka/model.rs @@ -0,0 +1,9 @@ +use serde::Serialize; +use uuid::Uuid; +use crate::broadcast::Notification; + +#[derive(Serialize)] +pub struct PushNotification { + pub to_user: Vec, + pub notification: Notification +} \ No newline at end of file diff --git a/src/kafka/notification_consumer.rs b/src/kafka/notification_consumer.rs deleted file mode 100644 index 9acca1d..0000000 --- a/src/kafka/notification_consumer.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; -use samsa::prelude::{BrokerAddress, ConsumeMessage, ConsumerGroup, ConsumerGroupBuilder, TcpConnection, TopicPartitionsBuilder}; -use log::{debug, error}; -use tokio_stream::StreamExt; -use crate::broadcast::{BroadcastChannel, SendNotification}; -use crate::core::KafkaConfig; - - -pub async fn start_consumer(config: KafkaConfig) { - let bootstrap_address = vec![BrokerAddress { - host: config.bootstrap_host, - port: config.bootstrap_port - }]; - - let partitions = config.partition; - let topic_name = config.topic; - let assignment = TopicPartitionsBuilder::new() - .assign(topic_name, partitions) - .build(); - - let consumer: ConsumerGroup = ConsumerGroupBuilder::::new( - bootstrap_address, - config.consumer_group, - assignment, - ).await - .expect("Could not create consumer.") - .client_id(config.client_id) - .build() - .await - .expect("Could not create consumer."); - - let stream = consumer.into_stream().throttle(Duration::from_secs(5)); - let broadcast = BroadcastChannel::get().clone(); - - // have to pin streams before iterating - tokio::pin!(stream); - - // Stream will do nothing unless consumed. - while let Some(message_stream) = stream.next().await { - match message_stream { - Ok(messages) => { - for entry in messages { - process_message_entry(entry, &broadcast).await; - } - }, - Err(e) => { - error!("Error: {e}"); - } - } - } -} - -async fn process_message_entry(entry: ConsumeMessage, broadcast: &Arc) { - match serde_json::from_slice::(&entry.value.to_vec()) { - Ok(value) => { - broadcast.send_event(value.body, &value.to_user).await; - debug!("Sent event, offset: {}", entry.offset); - }, - Err(err) => { - error!("Deserialization failed: {err}"); - } - } -} \ No newline at end of file diff --git a/src/kafka/push_notification_producer.rs b/src/kafka/push_notification_producer.rs new file mode 100644 index 0000000..80ed72c --- /dev/null +++ b/src/kafka/push_notification_producer.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; +use tracing::info; +use uuid::Uuid; +use crate::broadcast::Notification; +use crate::core::KafkaConfig; +use crate::errors::AppError; +use crate::kafka::event_producer::{KafkaEventProducer, LogEventProducer}; +use crate::kafka::EventProducer; + +pub enum PushNotificationProducer { + Kafka(KafkaEventProducer), + Logger(LogEventProducer) +} + +#[async_trait] +impl EventProducer for PushNotificationProducer { + async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError> { + match self { + PushNotificationProducer::Kafka(producer) => producer.send_notification(notification, to_user).await, + PushNotificationProducer::Logger(producer) => producer.send_notification(notification, to_user).await, + } + } +} + + +impl PushNotificationProducer { + pub fn new(use_kafka: bool, kafka_config: KafkaConfig) -> Self { + if use_kafka { + info!("Kafka-Producer initializing."); + PushNotificationProducer::Kafka(KafkaEventProducer::new(kafka_config)) + } else { + PushNotificationProducer::Logger(LogEventProducer::new()) + } + } +} \ No newline at end of file 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/lib.rs b/src/lib.rs index a8bac0e..f41afc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,16 @@ -pub mod api; pub mod core; pub mod database; pub mod model; pub mod broadcast; pub mod kafka; 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; +pub mod cache; + +pub mod welcome; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 8431246..5d67592 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,56 +1,33 @@ 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; +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 //https://github.com/AarambhDevHub/rust-backend-axum +//https://github.com/rust-lang/crates.io/ #[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(); - - 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; - }); - } + let config = init_configuration(); + welcome(); + //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 @@ -81,4 +58,22 @@ 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(); + + config } \ No newline at end of file diff --git a/src/messaging/handler.rs b/src/messaging/handler.rs new file mode 100644 index 0000000..cd531d7 --- /dev/null +++ b/src/messaging/handler.rs @@ -0,0 +1,20 @@ +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; +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> { + + 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/message_service.rs b/src/messaging/message_service.rs new file mode 100644 index 0000000..7888b9e --- /dev/null +++ b/src/messaging/message_service.rs @@ -0,0 +1,121 @@ +use std::str::FromStr; +use std::sync::Arc; +use uuid::Uuid; +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}; +use crate::model::LastMessagePreviewText; + +pub struct MessageService; + +impl MessageService { + + pub async fn send_message( + state: Arc, + message: NewMessage, + client_id: Uuid + ) -> Result { + + 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?; + } + + if !users.contains(&client_id) { + return Err(AppError::Blocked("User hasn't 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()) + })?; + + //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?; + + //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())) + })?; + 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> { + 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, username: String) -> LastMessagePreviewText { + match &msg.msg_body { + NewMessageBody::Text(body) => { + LastMessagePreviewText::Text { sender_username: username, text: body.text.clone()} + } + NewMessageBody::Media(body) => { + LastMessagePreviewText::Media { sender_username: username, media_type: body.media_type.clone()} + } + NewMessageBody::Reply(body) => { + LastMessagePreviewText::Reply { sender_username: username, reply_text: body.reply_text.clone()} + } + } + } + + + +} \ No newline at end of file diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs new file mode 100644 index 0000000..4946201 --- /dev/null +++ b/src/messaging/mod.rs @@ -0,0 +1,5 @@ +mod notifications; +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 63% rename from src/model/message.rs rename to src/messaging/model.rs index 470e2ec..6d65387 100644 --- a/src/model/message.rs +++ b/src/messaging/model.rs @@ -5,7 +5,11 @@ use chrono::{DateTime, Utc}; use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::User; +use validator::Validate; +use crate::broadcast::Notification; +use crate::broadcast::NotificationEvent::ChatMessage; +use crate::errors::AppError; +use crate::model::{LastMessagePreviewText, RoomMember}; #[derive(Debug, Deserialize, Serialize, Clone)] pub enum MsgType { @@ -48,6 +52,29 @@ 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) + } + + 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) + } } @@ -62,6 +89,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)] @@ -84,16 +126,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, @@ -122,16 +167,17 @@ 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 } } -#[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 } @@ -144,11 +190,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 } diff --git a/src/messaging/notifications.rs b/src/messaging/notifications.rs new file mode 100644 index 0000000..d6188c8 --- /dev/null +++ b/src/messaging/notifications.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; +use std::time::Duration; +use axum::{Extension, Json}; +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, Notification}; +use crate::core::AppState; +use crate::errors::{AppError, AppResponse}; +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> +) -> Sse>> { + + 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| { + + 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() + .interval(Duration::from_secs(5)) + .text("keep-alive-text") + ) +} + +#[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 diff --git a/src/messaging/routes.rs b/src/messaging/routes.rs new file mode 100644 index 0000000..1ef4eeb --- /dev/null +++ b/src/messaging/routes.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; +use axum::Router; +use axum::routing::{get, post}; +use crate::core::AppState; +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/notifications", get(get_latest_notification_events)) + .route("/api/sse", get(stream_server_events)) + .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 389da2f..deb6330 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,12 +1,7 @@ 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::*; -pub use queries::*; \ No newline at end of file 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/model/room.rs b/src/model/room.rs index 9004ef6..c38c5df 100644 --- a/src/model/room.rs +++ b/src/model/room.rs @@ -1,8 +1,10 @@ +use crate::utils::truncate_and_serialize; 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 { @@ -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 +} + +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(Debug, Deserialize, Serialize, Clone)] +#[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,26 +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_name: String, - pub room_image_url: Option, - pub created_at: DateTime, - pub users: Vec -} -#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, sqlx::Type, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ChatRoomListItemDTO { - 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/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 61% rename from src/database/room_database.rs rename to src/repository/room_repository.rs index 1e105da..4decac6 100644 --- a/src/database/room_database.rs +++ b/src/repository/room_repository.rs @@ -1,44 +1,22 @@ 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, LastMessagePreviewText, NewRoom, RoomType}; -#[derive(Debug, Clone)] -pub struct RoomDatabase { +#[derive(Clone)] +pub struct RoomRepository { pool: Pool, } -impl RoomDatabase { - - 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 } +impl RoomRepository { + + + pub fn new(pool: Pool) -> Self { + 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) } @@ -47,8 +25,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 +41,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, @@ -74,43 +52,65 @@ impl RoomDatabase { 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> { - let rooms = sqlx::query_as!( - ChatRoomListItemDTO, + pub async fn select_joined_user_by_id(&self, room_id: &Uuid, user_id: &Uuid) -> Result { + let users = sqlx::query_as!(RoomMember, 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 + 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!( + 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?; @@ -123,9 +123,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, + ChatRoomEntity, r#" SELECT room.id, @@ -133,23 +133,30 @@ impl RoomDatabase { 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 @@ -158,6 +165,13 @@ impl RoomDatabase { } 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, @@ -165,7 +179,8 @@ impl RoomDatabase { 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 @@ -176,7 +191,7 @@ impl RoomDatabase { 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, TRUE as "unread: _" "#, room_entity.id, room_entity.room_type.to_string(), @@ -205,7 +220,7 @@ impl RoomDatabase { 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?; @@ -241,12 +256,23 @@ impl RoomDatabase { } } - 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!(User, + let user = sqlx::query_as!(RoomMember, r#" SELECT users.id, @@ -258,10 +284,10 @@ impl RoomDatabase { 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) } @@ -291,16 +317,14 @@ impl RoomDatabase { /// 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> @@ -316,10 +340,9 @@ impl RoomDatabase { } - pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user: &User) -> 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 new file mode 100644 index 0000000..e5e7ccd --- /dev/null +++ b/src/repository/user_repository.rs @@ -0,0 +1,285 @@ +use sqlx::{query_as, Error, PgConnection, Pool, Postgres, Transaction}; +use uuid::Uuid; +use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipEntity}; + +#[derive(Clone)] +pub struct UserRepository { + pool: Pool, +} + +impl UserRepository { + + pub fn new(pool: Pool) -> Self { + UserRepository { pool } + } + + pub async fn start_transaction(&self) -> Result, Error> { + let tx = self.pool.begin().await?; + 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::<_, UserWithRelationshipEntity>( + r#"SELECT + r_user.id, + r_user.display_name, + r_user.profile_picture, + 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, + 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) + } + + 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, + r_user.role + 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::<_, UserWithRelationshipEntity>( + r#"SELECT + r_user.id, + r_user.display_name, + r_user.profile_picture, + 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, + 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, + 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 + (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, + u.role + 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!( + UserRelationshipEntity, + 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: &UserRelationshipEntity) -> 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 { + 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 + ).fetch_one(&mut *conn).await?; + Ok(entity) + } + + pub async fn delete_relationship_state( + &self, + conn: &mut PgConnection, + user_relationship: UserRelationshipEntity + ) -> 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(()) + } + + 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/repository/util.rs b/src/repository/util.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/repository/util.rs @@ -0,0 +1 @@ + diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs new file mode 100644 index 0000000..fbab962 --- /dev/null +++ b/src/rooms/handler.rs @@ -0,0 +1,203 @@ +use std::collections::HashSet; +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::{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; +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(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 { + 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> { + + 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, ¶ms.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 new file mode 100644 index 0000000..9723d24 --- /dev/null +++ b/src/rooms/mod.rs @@ -0,0 +1,4 @@ +pub mod routes; +mod timeline_service; +mod handler; +pub 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..eb98a3e --- /dev/null +++ b/src/rooms/room_service.rs @@ -0,0 +1,297 @@ +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::{ChatRoomDto, ChatRoomEntity, ChatRoomWithUserDTO, LastMessagePreviewText, MembershipStatus, NewRoom, RoomChangeType, 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.iter().map(|room| room.to_dto()).collect()) + } + + 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 { room: room.to_dto(), 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_room), Some(participator_room)) = (room_client, room_receiver) { + + let broadcast = BroadcastChannel::get(); + + broadcast.send_event(Notification { + 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_room.to_dto()}, + created_at: Utc::now() + }, &client_id).await; + + Ok(creator_room.to_dto()) + } else { + Err(AppError::ProcessingError("Newly created room is null.".to_string())) + } + } else { //is group room + let room_dto = room_entity.to_dto(); + BroadcastChannel::get().send_event_to_all( + users, + Notification { + body: crate::broadcast::NotificationEvent::NewRoom {room: room_dto.clone()}, + created_at: Utc::now() + } + ).await; + Ok(room_dto) + } + } + + 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.to_dto()) + } + + 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! + 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); + if user_to_exclude.is_some() { + return Err(AppError::BadRequest("User is already in this room.".to_string())) + } + + //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}; + 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?; + + //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()))?; + + 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(|| { + 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.to_dto()}, + 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 = 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!("{}/{}", state.env.object_db_config.bucket_name, room_id); + 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())) + }; + state.room_repository.update_room_img_url(&room_id, &object_id).await?; + let response = UploadResponse { + image_url: object_id.clone(), + image_name: format!("{}.jpeg", object_id), + }; + Ok(response) + } + +} + +async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { + 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?; + + 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, + 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?; + + 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 + state.message_repository.clear_chat_room_messages(&room.id).await?; + 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}, + 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(&room.id.to_string()).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().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 { + body: LeaveRoom {room_id: room.id}, + created_at: Utc::now() + }, + &leaving_user.id + ).await; + + Ok(()) + } +} + +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(|_| { + AppError::ProcessingError("Unable to cast message to dto.".to_string()) + })?; + + let notification = Notification { + body: RoomChangeEvent{message: mapped_msg, room_preview_text: preview_text}, + 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/routes.rs b/src/rooms/routes.rs new file mode 100644 index 0000000..c31b572 --- /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::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(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(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_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/api/router.rs b/src/router.rs similarity index 58% rename from src/api/router.rs rename to src/router.rs index a91a1bc..d9611f0 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. @@ -26,32 +24,21 @@ use crate::keycloak::PassthroughMode; pub async fn init_router(app_state: AppState) -> Router { let origin = app_state.env.cors_origin.clone(); let cors = CorsLayer::new() - .allow_origin(origin.parse::().unwrap()) + .allow_origin(origin.parse::().expect("Invalid CORS Origin")) .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. 🤗" })) .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 @@ -67,7 +54,7 @@ pub async fn init_router(app_state: AppState) -> Router { fn init_auth(config: TokenIssuer) -> KeycloakAuthLayer { let keycloak_auth_instance = KeycloakAuthInstance::new( KeycloakConfig::builder() - .server(Url::parse(&config.iss_host).unwrap()) + .server(Url::parse(&config.iss_host).expect("Invalid Keycloak Host")) .realm(config.iss_realm) .build(), ); diff --git a/src/user_relationship/handler.rs b/src/user_relationship/handler.rs new file mode 100644 index 0000000..585d449 --- /dev/null +++ b/src/user_relationship/handler.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; +use axum::extract::{Path, Query, State}; +use axum::{Extension, Json}; +use uuid::Uuid; +use crate::core::AppState; +use crate::core::cursor::{decode_cursor, CursorResults}; +use crate::errors::{AppError, AppResponse}; +use crate::keycloak::decode::KeycloakToken; +use crate::rooms::room_service::RoomService; +use crate::user_relationship::model::{RelationshipStateResponse, User, UserPaginationCursor, UserWithRelationshipDto}; +use crate::user_relationship::query_param::UserSearchParams; +use crate::user_relationship::user_service::UserService; + + +pub async fn handle_search_user_by_id( + State(state): State>, + Path(user_id): Path, + Extension(token): Extension>, +) -> 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> { + + if token.subject == user_id { + return Err(AppError::ValidationError("Cannot friendship yourself.".to_string())); + } + 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>, +)-> AppResponse> { + + if token.subject == user_id { + return Err(AppError::ValidationError("Cannot ignore yourself.".to_string())); + } + 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?; + } + 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>, +)-> 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/mod.rs b/src/user_relationship/mod.rs new file mode 100644 index 0000000..f39dc0b --- /dev/null +++ b/src/user_relationship/mod.rs @@ -0,0 +1,6 @@ +pub mod model; +mod utils; +mod handler; +pub mod routes; +mod query_param; +pub mod user_service; \ 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..33fa361 --- /dev/null +++ b/src/user_relationship/model.rs @@ -0,0 +1,247 @@ +use std::error::Error; +use std::fmt; +use std::fmt::{Display, Formatter}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Row, Type}; +use uuid::Uuid; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FriendRequestResult { + pub id: Uuid, + pub from_user: User, +} + +#[derive(Debug, Clone)] +pub struct UserRelationshipEntity { + pub user_a_id: Uuid, + pub user_b_id: Uuid, + pub state: RelationshipState, + pub relationship_change_timestamp: DateTime +} + +impl UserRelationshipEntity { + + pub fn resolve_relationship_state( + &self, + client_id: &Uuid + ) -> Relationship { + + let relationship = self; + + match relationship.state { + + RelationshipState::FRIEND => Relationship::Friend, + + RelationshipState::A_BLOCKED => { + if relationship.user_a_id == *client_id { + Relationship::ClientBlocked + } else { + Relationship::ClientGotBlocked + } + } + + RelationshipState::B_BLOCKED => { + if relationship.user_b_id == *client_id { + Relationship::ClientBlocked + } else { + Relationship::ClientGotBlocked + } + } + + RelationshipState::ALL_BLOCKED => { + if relationship.user_b_id == *client_id || relationship.user_a_id == *client_id { + Relationship::ClientBlocked + } else { + Relationship::ClientGotBlocked + } + } + + RelationshipState::A_INVITED => { + if relationship.user_a_id == *client_id { + Relationship::InviteSent + } else { + Relationship::InviteReceived + } + } + + RelationshipState::B_INVITED => { + if relationship.user_b_id == *client_id { + Relationship::InviteSent + } else { + 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, + 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(UserWithRelationshipEntity { + 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, Copy)] +#[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RelationshipState { + A_BLOCKED, + B_BLOCKED, + ALL_BLOCKED, + FRIEND, + A_INVITED, + 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, + 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, + pub role: String +} + +#[derive(Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +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/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 new file mode 100644 index 0000000..0a66d5d --- /dev/null +++ b/src/user_relationship/routes.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; +use axum::Router; +use axum::routing::{delete, get, post}; +use crate::core::AppState; +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> { + + Router::new() + .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_service.rs b/src/user_relationship/user_service.rs new file mode 100644 index 0000000..86fb6c1 --- /dev/null +++ b/src/user_relationship/user_service.rs @@ -0,0 +1,376 @@ +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::{Relationship, RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipDto}; + + +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| { + item.to_dto(current_user_id) + }).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)) + })?; + + Ok(user.to_dto(current_user_id)) + } + + 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 = UserRelationshipEntity { + 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 { + 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(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, _) => { + 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 + } + } + }; + 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) + } 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 = UserRelationshipEntity { + user_a_id, + user_b_id, + state: relationship_state.clone(), + relationship_change_timestamp: Utc::now(), + }; + state.user_repository.insert_relationship(&mut tx, &init_relationship).await?; + tx.commit().await?; + Ok(init_relationship.resolve_relationship_state(&client_id)) + } + } + + 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; + let state = match (relationship.state.clone(), is_client_user_a) { + (RelationshipState::ALL_BLOCKED, true) => { // Client was A, only B blocking now + 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 + 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 + state.user_repository.delete_relationship_state( + &mut tx, + relationship + ).await?; + None + }, + (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(), + )); + } + }; + tx.commit().await?; + match state { + Some(entity) => { Ok(Some(entity.resolve_relationship_state(&client_id))) }, + None => Ok(None) + } + } + + 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 diff --git a/src/user_relationship/utils.rs b/src/user_relationship/utils.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..cbafd74 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; +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; + + +pub async fn check_user_in_room( + state: &Arc, + user_id: &Uuid, + room_id: &Uuid, +) -> Result<(), AppError> { + let is_in = state.room_repository.is_user_in_room(user_id, room_id).await?; + if is_in { + Ok(()) + } else { + Err(AppError::Blocked("Invalid permissions to interact with this room".to_string())) + } +} + +pub fn crop_image_from_center( + data: &Bytes, + target_width: u32, + target_height: u32, +) -> Result { + + let img = match image::load_from_memory(data) { + Ok(img) => img, + Err(err) => return Err(err) + }; + + let (original_width, original_height) = img.dimensions(); + + if original_width < target_width || original_height < target_height { + return Ok(data.clone()) + }; + + let x = (original_width - target_width) / 2; + let y = (original_height - target_height) / 2; + let cropped = img.crop_imm(x, y, target_width, target_height).to_rgb8(); + + let mut buffer = Cursor::new(Vec::new()); + match cropped.write_to(&mut buffer, image::ImageFormat::Jpeg){ + Ok(_) => { + Ok(Bytes::from(buffer.into_inner())) + }, + Err(err) => Err(err) + } +} + +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) + } +} 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