diff --git a/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json b/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json new file mode 100644 index 0000000..66e8048 --- /dev/null +++ b/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM judge_team_assignments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc" +} diff --git a/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json b/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json new file mode 100644 index 0000000..5bb7735 --- /dev/null +++ b/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO roles(id, user_id, tournament_id, roles)\n VALUES ($1, $2, $3, $4) RETURNING roles", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "roles", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray" + ] + }, + "nullable": [ + true + ] + }, + "hash": "091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6" +} diff --git a/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json b/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json new file mode 100644 index 0000000..21b902f --- /dev/null +++ b/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO login_tokens (id, token_hash, user_id, used)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16" +} diff --git a/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json b/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json new file mode 100644 index 0000000..1e94767 --- /dev/null +++ b/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM locations WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "tournament_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "remarks", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374" +} diff --git a/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json b/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json new file mode 100644 index 0000000..0b0e899 --- /dev/null +++ b/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd" +} diff --git a/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json b/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json new file mode 100644 index 0000000..ea8b0ab --- /dev/null +++ b/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM rooms WHERE location_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1" +} diff --git a/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json b/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json new file mode 100644 index 0000000..c8b655e --- /dev/null +++ b/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397" +} diff --git a/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json b/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json new file mode 100644 index 0000000..f62678a --- /dev/null +++ b/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM users WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7" +} diff --git a/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json b/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json new file mode 100644 index 0000000..8c190d3 --- /dev/null +++ b/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE rooms set name = $1,\n remarks = $2, location_id = $3\n WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8" +} diff --git a/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json b/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json new file mode 100644 index 0000000..fb4ee8f --- /dev/null +++ b/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE tournament_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe" +} diff --git a/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json b/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json new file mode 100644 index 0000000..6ce0112 --- /dev/null +++ b/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE locations set name = $1, address = $2,\n remarks = $3, tournament_id = $4\n WHERE id = $5", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6" +} diff --git a/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json b/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json new file mode 100644 index 0000000..9e14891 --- /dev/null +++ b/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM rooms WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df" +} diff --git a/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json b/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json new file mode 100644 index 0000000..4ac23b4 --- /dev/null +++ b/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM locations WHERE tournament_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "tournament_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "remarks", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f" +} diff --git a/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json b/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json new file mode 100644 index 0000000..eb6b1e0 --- /dev/null +++ b/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM locations WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643" +} diff --git a/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json b/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json new file mode 100644 index 0000000..7fd738e --- /dev/null +++ b/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO rooms(id, name, remarks, location_id, is_occupied)\n VALUES ($1, $2, $3, $4, $5) RETURNING id, name, remarks, location_id, is_occupied", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid", + "Bool" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be" +} diff --git a/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json b/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json new file mode 100644 index 0000000..b1ae5d0 --- /dev/null +++ b/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM locations WHERE name = $1 AND tournament_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b" +} diff --git a/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json b/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json new file mode 100644 index 0000000..c7d1051 --- /dev/null +++ b/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE login_tokens SET used = true WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8" +} diff --git a/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json b/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json new file mode 100644 index 0000000..8434731 --- /dev/null +++ b/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM rooms WHERE name = $1 AND location_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4" +} diff --git a/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json b/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json new file mode 100644 index 0000000..94caeb0 --- /dev/null +++ b/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418" +} diff --git a/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json b/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json new file mode 100644 index 0000000..73150a3 --- /dev/null +++ b/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM rooms WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9" +} diff --git a/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json b/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json new file mode 100644 index 0000000..7ce939d --- /dev/null +++ b/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3\n RETURNING roles", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "roles", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9" +} diff --git a/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json b/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json new file mode 100644 index 0000000..62b1d31 --- /dev/null +++ b/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528" +} diff --git a/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json b/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json new file mode 100644 index 0000000..7d53243 --- /dev/null +++ b/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO locations(id, name, address, remarks, tournament_id)\n VALUES ($1, $2, $3, $4, $5) RETURNING id, name, address, remarks, tournament_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false + ] + }, + "hash": "da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd" +} diff --git a/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json b/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json new file mode 100644 index 0000000..d2e871a --- /dev/null +++ b/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM sessions WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629" +} diff --git a/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json b/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json new file mode 100644 index 0000000..5c7d502 --- /dev/null +++ b/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM login_tokens WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "used", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "expiry", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334" +} diff --git a/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json b/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json new file mode 100644 index 0000000..7e55f82 --- /dev/null +++ b/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712" +} diff --git a/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json b/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json new file mode 100644 index 0000000..e3c1294 --- /dev/null +++ b/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90" +} diff --git a/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json b/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json new file mode 100644 index 0000000..132b09a --- /dev/null +++ b/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id)\n VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d" +} diff --git a/migrations/20241219_init.sql b/migrations/20241219_init.sql index 6b8a288..5f85399 100644 --- a/migrations/20241219_init.sql +++ b/migrations/20241219_init.sql @@ -78,3 +78,34 @@ CREATE TABLE IF NOT EXISTS debate_judge_assignments ( judge_user_id UUID NOT NULL REFERENCES users(id), debate_id UUID NOT NULL REFERENCES debates(id) ); + +CREATE TABLE IF NOT EXISTS locations ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + tournament_id UUID NOT NULL REFERENCES tournaments(id), + address TEXT, + remarks TEXT +); + +CREATE TABLE IF NOT EXISTS rooms ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + location_id UUID NOT NULL REFERENCES locations(id), + remarks TEXT, + is_occupied BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS judge_team_assignments ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + judge_user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + tournament_id UUID NOT NULL REFERENCES tournaments(id) +); + +CREATE TABLE IF NOT EXISTS login_tokens ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + token_hash TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id), + used BOOLEAN NOT NULL DEFAULT FALSE, + expiry TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '2 days' +); diff --git a/src/omni_error.rs b/src/omni_error.rs index b497948..7e88a4c 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -11,6 +11,10 @@ const UNAUTHORIZED_MESSAGE: &str = "Unauthorized"; const BAD_REQUEST: &str = "Bad Request"; const INSUFFICIENT_PERMISSIONS_MESSAGE: &str = "You don't have permissions required to perform this operation"; +const REFERRING_TO_A_NONEXISTENT_RESOURCE: &str = "Referring to a nonexistent resource"; +const ROLES_PARSING_MESSAGE: &str = "Failed to parse user roles"; +const NOT_A_JUDGE_MESSAGE: &str = + "This user is not a Judge and therefore cannot have affiliations"; #[derive(thiserror::Error, Debug)] pub enum OmniError { @@ -48,6 +52,12 @@ pub enum OmniError { BadRequestError, #[error("{INSUFFICIENT_PERMISSIONS_MESSAGE}")] InsufficientPermissionsError, + #[error("REFERRING_TO_A_NONEXISTENT_RESOURCE")] + ReferringToNonexistentResourceError, + #[error("ROLES_PARSING_MESSAGE")] + RolesParsingError, + #[error{"NOT_A_JUDGE_MESSAGE"}] + NotAJudgeAffiliationError, } impl IntoResponse for OmniError { @@ -87,6 +97,13 @@ impl OmniError { return false; } + pub fn is_not_found_error(&self) -> bool { + match self { + OmniError::ResourceNotFoundError => true, + _ => false, + } + } + pub fn respond(self) -> Response { use OmniError as E; const ISE: StatusCode = StatusCode::INTERNAL_SERVER_ERROR; @@ -106,10 +123,7 @@ impl OmniError { return (StatusCode::CONFLICT, RESOURCE_ALREADY_EXISTS_MESSAGE) .into_response(); } else if e.is_foreign_key_violation() { - return ( - StatusCode::BAD_REQUEST, - "Referring to a nonexistent resource", - ) + return OmniError::ReferringToNonexistentResourceError .into_response(); } else { (ISE, "SQLx Error").into_response() @@ -142,6 +156,15 @@ impl OmniError { E::InsufficientPermissionsError => { (StatusCode::FORBIDDEN, self.clerr()).into_response() } + E::ReferringToNonexistentResourceError => { + (StatusCode::NOT_FOUND, self.clerr()).into_response() + } + E::RolesParsingError => { + (StatusCode::BAD_REQUEST, self.clerr()).into_response() + } + E::NotAJudgeAffiliationError => { + (StatusCode::CONFLICT, self.clerr()).into_response() + } } } @@ -163,6 +186,9 @@ impl OmniError { E::UnauthorizedError => UNAUTHORIZED_MESSAGE, E::BadRequestError => BAD_REQUEST, E::InsufficientPermissionsError => INSUFFICIENT_PERMISSIONS_MESSAGE, + E::ReferringToNonexistentResourceError => REFERRING_TO_A_NONEXISTENT_RESOURCE, + E::RolesParsingError => ROLES_PARSING_MESSAGE, + E::NotAJudgeAffiliationError => NOT_A_JUDGE_MESSAGE, } .to_string() } diff --git a/src/routes/affiliation_routes.rs b/src/routes/affiliation_routes.rs new file mode 100644 index 0000000..5a7fc55 --- /dev/null +++ b/src/routes/affiliation_routes.rs @@ -0,0 +1,299 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use sqlx::query_as; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + setup::AppState, + tournament::{ + affiliation::{self, Affiliation, AffiliationPatch}, + roles::Role, + Tournament, + }, + users::{permissions::Permission, TournamentUser, User}, +}; + +pub fn route() -> Router { + Router::new() + .route( + "/user/:user_id/tournament/:tournament_id/affiliation", + get(get_affiliations).post(create_affiliation), + ) + .route( + "/user/:user_id/tournament/:tournament_id/affiliation/:id", + get(get_affiliation_by_id) + .patch(patch_affiliation_by_id) + .delete(delete_affiliation_by_id), + ) +} + +/// Create a new affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(post, request_body=Affiliation, path = "/user/{user_id}/tournament/{tournament_id}/affiliation", + responses + ( + ( + status=200, description = "Affiliation created successfully", + body=Affiliation, + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ) +)] +async fn create_affiliation( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, + Json(affiliation): Json, +) -> Result { + if !params_and_affiliation_fields_match(&affiliation, &user_id, &tournament_id) { + return Err(OmniError::BadRequestError); + } + + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + affiliation.validate(pool).await?; + match Affiliation::post(affiliation, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => { + error!("Error creating a new affiliation: {e}"); + Err(e) + } + } +} + +fn params_and_affiliation_fields_match( + affiliation: &Affiliation, + user_id: &Uuid, + tournament_id: &Uuid, +) -> bool { + if !(&affiliation.judge_user_id == user_id) { + return false; + } + if !(&affiliation.tournament_id == tournament_id) { + return false; + } + return true; +} + +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/affiliation", + responses + ( + (status=200, description = "Ok", body=Vec), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ) +)] +/// Get a list of all user affiliations. +/// +/// Available only to Organizers and the infrastructure admin. +async fn get_affiliations( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let affiliated_user = User::get_by_id(user_id, pool).await?; + if !affiliated_user + .has_role(Role::Judge, tournament_id, pool) + .await? + { + return Err(OmniError::NotAJudgeAffiliationError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match query_as!( + Affiliation, + "SELECT * FROM judge_team_assignments WHERE tournament_id = $1", + tournament_id + ) + .fetch_all(&state.connection_pool) + .await + { + Ok(affiliations) => Ok(Json(affiliations).into_response()), + Err(e) => { + error!( + "Error getting affiliations of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// Get details of an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", + responses( + (status=200, description = "Ok", body=Affiliation), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ), +)] +async fn get_affiliation_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Affiliation::get_by_id(id, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => { + error!("Error getting a affiliation with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(patch, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", + request_body=Affiliation, + responses( + (status=200, description = "Affiliation patched successfully", body=Affiliation), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + ( + status=409, + description = "This affiliation already exists", + ), + (status=500, description = "Internal server error"), + ) +)] +#[axum::debug_handler] +async fn patch_affiliation_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, + Json(new_affiliation): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let old_affiliation = Affiliation::get_by_id(id, pool).await?; + + let new_affiliation = Affiliation { + id: old_affiliation.id, + judge_user_id: new_affiliation + .judge_user_id + .unwrap_or(old_affiliation.judge_user_id), + tournament_id: new_affiliation + .tournament_id + .unwrap_or(old_affiliation.tournament_id), + team_id: new_affiliation.team_id.unwrap_or(old_affiliation.team_id), + }; + new_affiliation.validate(pool).await?; + + match old_affiliation.patch(new_affiliation, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(delete, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", + responses + ( + (status=204, description = "Affiliation deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + ), +)] +async fn delete_affiliation_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let affiliation = Affiliation::get_by_id(id, pool).await?; + match affiliation.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a affiliation with id {id}: {e}"); + Err(e)? + } + } +} diff --git a/src/routes/attendee_routes.rs b/src/routes/attendee_routes.rs index 372745b..a941dc9 100644 --- a/src/routes/attendee_routes.rs +++ b/src/routes/attendee_routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::{get, post}, Json, Router, }; -use sqlx::{query, query_as, Pool, Postgres}; +use sqlx::{query, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -46,9 +46,10 @@ pub fn route() -> Router { body=Attendee, example=json!(get_attendee_example()) ), - (status=400, description = "Bad request",), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to create attendees within this tournament", ), (status=404, description = "Tournament not found"), @@ -58,7 +59,8 @@ pub fn route() -> Router { ( status=500, description = "Internal server error", ), - ) + ), + tag="attendee" )] #[axum::debug_handler] async fn create_attendee( @@ -74,7 +76,7 @@ async fn create_attendee( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } if !attendee.position.is_none() { @@ -109,15 +111,17 @@ async fn create_attendee( example=json!(get_attendees_list_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not get to create attendees within this tournament", ), (status=404, description = "Tournament not found"), ( status=500, description = "Internal server error", ), - ) + ), + tag="attendee" )] /// Get a list of all attendees async fn get_attendees( @@ -132,7 +136,7 @@ async fn get_attendees( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Attendee::get_all(pool).await { @@ -152,8 +156,9 @@ async fn get_attendees( example=json!(get_attendee_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to get attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -161,6 +166,7 @@ async fn get_attendees( status=500, description = "Internal server error", ), ), + tag="attendee" )] async fn get_attendee_by_id( Path(id): Path, @@ -175,7 +181,7 @@ async fn get_attendee_by_id( match tournament_user.has_permission(Permission::ReadAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Attendee::get_by_id(id, &state.connection_pool).await { @@ -197,15 +203,17 @@ async fn get_attendee_by_id( example=json!(get_attendee_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to patch attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), (status=409, description = "Attendee position is duplicated"), (status=422, description = "Attendee position out of range [1-4]"), (status=500, description = "Internal server error"), - ) + ), + tag="attendee" )] async fn patch_attendee_by_id( Path((id, tournament_id)): Path<(Uuid, Uuid)>, @@ -220,7 +228,7 @@ async fn patch_attendee_by_id( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } if !new_attendee.position.is_none() { @@ -249,8 +257,9 @@ async fn patch_attendee_by_id( ( (status=204, description = "Attendee deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to delete attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -258,6 +267,7 @@ async fn patch_attendee_by_id( status=500, description = "Internal server error", ), ), + tag="attendee" )] async fn delete_attendee_by_id( Path(id): Path, @@ -272,7 +282,7 @@ async fn delete_attendee_by_id( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let attendee = Attendee::get_by_id(id, pool).await?; diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1af4b8a..b2ccaa7 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -14,7 +14,7 @@ use crate::{ }, }; use axum::{ - extract::State, + extract::{Path, State}, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -24,11 +24,13 @@ use serde::Deserialize; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; use utoipa::ToSchema; +use uuid::Uuid; pub fn route() -> Router { Router::new() .route("/auth/login", post(auth_login)) .route("/auth/clear", get(auth_clear)) + .route("/auth/login/:token", post(single_use_login)) } #[derive(Deserialize, ToSchema)] @@ -44,6 +46,8 @@ pub struct LoginRequest { /// Providing the token either by including it in the /// request header or sending the cookie is required /// to perform any further operations. +/// By default, the only existing account is the infrastructure admin +/// with username and password "admin". #[utoipa::path(post, path = "/auth/login", request_body=LoginRequest, responses ( @@ -59,7 +63,7 @@ pub struct LoginRequest { ) ) ] -pub async fn auth_login( +async fn auth_login( cookies: Cookies, State(state): State, Json(body): Json, @@ -84,6 +88,41 @@ pub async fn auth_login( (StatusCode::OK, token).into_response() } +#[utoipa::path( + post, + path = "/auth/login/{token}", + responses( + ( + status = 200, + description = "Returns an auth token to be used for authentication in subsequent requests", + body=String, + example=json!("k1ShPhFwn11_0hBQF2Xh56iB-zGx7mwymarrt39QYLo") + ), + (status = 401, description = "Provided token was invalid"), + (status = 403, description = "Provided token was used used or expired"), + (status = 500, description = "Internal server error") + ) +)] +/// Log in with a single-use token +/// +/// This endpoint can be used to utilize single-use login tokens +/// generated with /user/{user_id}/login_token. +async fn single_use_login( + cookies: Cookies, + State(state): State, + Path(token): Path, +) -> Result { + let pool = &state.connection_pool; + let user = User::auth_via_link(&token, pool).await?; + let (_, token) = match Session::create(&user.id, pool).await { + Ok(o) => o, + Err(e) => Err(e)?, + }; + + set_session_token_cookie(&token, cookies); + Ok((StatusCode::OK, token).into_response()) +} + const TOO_MANY_TOKENS: &str = "Please provide one session token to destroy at a time."; const NO_TOKENS: &str = "Please provide a session token to destroy."; const SESSION_DESTROYED: &str = "Logged out - Session destroyed"; diff --git a/src/routes/debate_routes.rs b/src/routes/debate_routes.rs index 2133d14..902ae8e 100644 --- a/src/routes/debate_routes.rs +++ b/src/routes/debate_routes.rs @@ -6,7 +6,6 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::query_as; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -30,13 +29,15 @@ pub fn route() -> Router { body=Vec, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read debates within this tournament", ), (status=404, description = "Tournament not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] /// Get a list of all debates /// @@ -53,7 +54,7 @@ async fn get_debates( match tournament_user.has_permission(Permission::ReadDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Tournament::get_by_id(tournament_id, pool).await?.get_debates(pool).await @@ -77,13 +78,15 @@ async fn get_debates( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament", ), (status=404, description = "Tournament or attendee not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] async fn create_debate( State(state): State, @@ -98,7 +101,7 @@ async fn create_debate( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::post(json, &state.connection_pool).await { @@ -121,13 +124,15 @@ async fn create_debate( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read debates within this tournament", ), (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), ), + tag="debate" )] async fn get_debate_by_id( State(state): State, @@ -142,7 +147,7 @@ async fn get_debate_by_id( match tournament_user.has_permission(Permission::ReadDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::get_by_id(id, &state.connection_pool).await { @@ -168,13 +173,15 @@ async fn get_debate_by_id( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament" ), (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] async fn patch_debate_by_id( State(state): State, @@ -189,7 +196,7 @@ async fn patch_debate_by_id( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let existing_debate = Debate::get_by_id(id, &state.connection_pool).await?; @@ -213,13 +220,15 @@ async fn patch_debate_by_id( ( (status=204, description = "Debate deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament" ), (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), ), + tag="debate" )] async fn delete_debate_by_id( State(state): State, @@ -234,7 +243,7 @@ async fn delete_debate_by_id( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::get_by_id(id, &state.connection_pool).await { diff --git a/src/routes/location_routes.rs b/src/routes/location_routes.rs new file mode 100644 index 0000000..b3f5800 --- /dev/null +++ b/src/routes/location_routes.rs @@ -0,0 +1,323 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use sqlx::{query, query_as, Error, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{omni_error::OmniError, setup::AppState, tournament::{location::{Location, LocationPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; + +const DUPLICATE_NAME_ERROR: &str = "Location with this name already exists within the scope of the tournament, to which the location is assigned."; + +pub fn route() -> Router { + Router::new() + .route("/tournament/:tournament_id/location", get(get_locations).post(create_location)) + .route( + "/tournament/:tournament_id/location/:id", + get(get_location_by_id) + .patch(patch_location_by_id) + .delete(delete_location_by_id), + ) +} + +/// Create a new location +/// +/// Available only to the tournament Organizers. +#[utoipa::path(post, request_body=Location, path = "/tournament/{tournament_id}/location", + responses + ( + ( + status=200, description = "Location created successfully", + body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ), + tag="location" +)] +async fn create_location( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + if location_with_name_exists_in_tournament(&json.name, &tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match Location::post(json, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => { + error!("Error creating a new location: {e}"); + Err(e) + }, + } +} + +#[utoipa::path(get, path = "/tournament/{tournament_id}/location", + responses + ( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_locations_list_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ), + tag="location" +)] +/// Get a list of all locations +/// +/// The user must be given a role within this tournament to use this endpoint. +async fn get_locations( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match query_as!(Location, "SELECT * FROM locations WHERE tournament_id = $1", tournament_id) + .fetch_all(&state.connection_pool) + .await + { + Ok(locations) => Ok(Json(locations).into_response()), + Err(e) => { + error!("Error getting a list of locations: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing location +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{id}", + responses( + ( + status=200, description = "Ok", body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ), + tag="location" +)] +async fn get_location_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path( (_tournament_id, id)): Path<(Uuid, Uuid)> +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Location::get_by_id(id, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => { + error!("Error getting a location with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing location +/// +/// Available only to the tournament Organizers. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/location/{id}", + request_body=Location, + responses( + ( + status=200, description = "Location patched successfully", + body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + ( + status=409, + description = DUPLICATE_NAME_ERROR, + ), + (status=500, description = "Internal server error"), + ), + tag="location" +)] +async fn patch_location_by_id( + Path((_tournament_id, id)): Path<(Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_location): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let location = Location::get_by_id(id, pool).await?; + + let name = new_location.name.clone(); + if name.is_some() { + if location_with_name_exists_in_tournament(&name.unwrap(), &location.tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + } + + match location.patch(new_location, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing location +/// +/// This operation is only allowed when there are no entities +/// referencing this location. Available only to the tournament Organizers. +#[utoipa::path(delete, path = "/tournament/{tournament_id}/location/{id}", + responses + ( + (status=204, description = "Location deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ), + tag="location" +)] +async fn delete_location_by_id( + Path((tournament_id, id)): Path<(Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let location = Location::get_by_id(id, pool).await?; + match location.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a location with id {id}: {e}"); + Err(e)? + } + } +} + +async fn location_with_name_exists_in_tournament( + name: &String, + tournament_id: &Uuid, + connection_pool: &Pool, +) -> Result { + match query!( + "SELECT EXISTS(SELECT 1 FROM locations WHERE name = $1 AND tournament_id = $2)", + name, + tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(result) => Ok(result.exists.unwrap()), + Err(e) => Err(e), + } +} + +fn get_location_example() -> String { + r#" + { + "address": "PoznaƄ, Poland", + "name": "ZSK", + "remarks": "Where debatecore was born", + "tournament_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + "# + .to_owned() +} + +fn get_locations_list_example() -> String { + r#" + [ + { + "address": "PoznaƄ, Poland", + "name": "ZSK", + "remarks": "Where debatecore was born", + "tournament_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + { + "address": "Bydgoszcz, Poland", + "name": "Library of the Kazimierz Wielki University", + "remarks": "Where Debate Team Buster prevailed", + "tournament_id": "57a85f64-5784-4562-4acc-35163f66afa6" + }, + ] + "# + .to_owned() +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b0f9f15..4adca70 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,16 +2,21 @@ use axum::Router; use crate::setup::AppState; +mod affiliation_routes; mod attendee_routes; mod auth; mod debate_routes; mod health_check; mod infradmin_routes; +mod location_routes; mod motion_routes; +mod roles_routes; +mod room_routes; mod swagger; mod team_routes; mod teapot; mod tournament_routes; +mod user_routes; mod version; pub fn routes() -> Router { @@ -27,4 +32,9 @@ pub fn routes() -> Router { .merge(attendee_routes::route()) .merge(motion_routes::route()) .merge(debate_routes::route()) + .merge(location_routes::route()) + .merge(room_routes::route()) + .merge(user_routes::route()) + .merge(roles_routes::route()) + .merge(affiliation_routes::route()) } diff --git a/src/routes/motion_routes.rs b/src/routes/motion_routes.rs index da1e836..8686af9 100644 --- a/src/routes/motion_routes.rs +++ b/src/routes/motion_routes.rs @@ -1,4 +1,4 @@ -use crate::{omni_error::OmniError, setup::AppState, tournament::{motion::{Motion, MotionPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; +use crate::{omni_error::OmniError, setup::AppState, tournament::motion::{Motion, MotionPatch}, users::{permissions::Permission, TournamentUser}}; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, @@ -6,7 +6,6 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::query_as; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -29,7 +28,9 @@ pub fn route() -> Router { status=200, description = "Ok", body=Vec, example=json!(get_motions_list_example()) -)))] + )), + tag="motion" +)] /// Get a list of all motions /// /// The user must be given a role within this tournament to use this endpoint. @@ -45,7 +46,7 @@ async fn get_motions( match tournament_user.has_permission(Permission::ReadMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Motion::get_all(pool).await @@ -72,14 +73,15 @@ async fn get_motions( example=json!(get_motion_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found"), (status=409, description = DUPLICATE_MOTION_ERROR) - - ) + ), + tag="motion" )] async fn create_motion( State(state): State, @@ -94,7 +96,7 @@ State(state): State, match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Motion::post(json, &state.connection_pool).await { @@ -108,10 +110,11 @@ State(state): State, /// Get details of an existing motion /// /// The user must be given a role within this tournament to use this endpoint. -#[utoipa::path(get, path = "/motion/{id}", +#[utoipa::path(get, path = "/tournament/{tournament_id}/motion/{id}", responses((status=200, description = "Ok", body=Motion, example=json!(get_motion_example()) )), + tag="motion" )] async fn get_motion_by_id( Path(id): Path, @@ -126,7 +129,7 @@ async fn get_motion_by_id( match tournament_user.has_permission(Permission::ReadMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Motion::get_by_id(id, &state.connection_pool).await { @@ -147,12 +150,14 @@ async fn get_motion_by_id( example=json!(get_motion_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found") - ) + ), + tag="motion" )] async fn patch_motion_by_id( Path(id): Path, @@ -168,7 +173,7 @@ async fn patch_motion_by_id( match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let existing_motion = Motion::get_by_id(id, pool).await?; @@ -186,12 +191,14 @@ async fn patch_motion_by_id( ( (status=204, description = "Motion deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found") ), + tag="motion" )] async fn delete_motion_by_id( @@ -207,7 +214,7 @@ async fn delete_motion_by_id( match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let motion = Motion::get_by_id(id, pool).await?; diff --git a/src/routes/roles_routes.rs b/src/routes/roles_routes.rs new file mode 100644 index 0000000..a6a3bde --- /dev/null +++ b/src/routes/roles_routes.rs @@ -0,0 +1,271 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use strum::VariantArray; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + setup::AppState, + tournament::roles::Role, + users::{permissions::Permission, TournamentUser, User}, +}; + +pub fn route() -> Router { + Router::new().route( + "/user/:user_id/tournament/:tournament_id/roles", + post(create_user_roles) + .get(get_user_roles) + .patch(patch_user_roles) + .delete(delete_user_roles), + ) +} + +/// Grant roles to a user +/// +/// Available only to Organizers and and the infrastructure admin. +#[utoipa::path( + post, + request_body=Vec, + path = "/user/{user_id}/tournament/{tournament_id}/roles", + responses( + ( + status=200, description = "Roles created successfully", + body=Vec + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "User of tournament not found"), + (status=409, description = "The user is already granted roles within this tournament. Use PATCH method to modify user roles"), + (status=500, description = "Internal server error"), + ), + tag = "roles" +)] +async fn create_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, + Json(json): Json>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let user_to_be_granted_roles = User::get_by_id(user_id, pool).await?; + let roles = user_to_be_granted_roles + .get_roles(tournament_id, pool) + .await?; + if !roles.is_empty() { + return Err(OmniError::ResourceAlreadyExistsError); + } + + match Role::post(user_id, tournament_id, json, pool).await { + Ok(role) => Ok(Json(role).into_response()), + Err(e) => { + error!( + "Error creating roles for user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// List roles a user is given within a tournament +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/roles", + responses( + (status=200, description = "Ok", body=Vec, + example=json!(get_roles_example()) + ), + (status=400, description="Bad request"), + (status=401, description="The user is not permitted to see roles, meaning they don't have any role within this tournament"), + (status=404, description="User or tournament not found"), + (status=500, description="Internal server error"), + ), + tag = "roles" +)] +async fn get_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.roles.is_empty() { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let requested_user = User::get_by_id(user_id, pool).await?; + match requested_user.get_roles(tournament_id, pool).await { + Ok(roles) => Ok(Json(roles).into_response()), + Err(e) => { + error!( + "Error getting roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// Overwrite roles a user is given within a tournament +/// +/// Available only to the tournament Organizers and the infrastructure admin. +#[utoipa::path(patch, path = "/user/{user_id}/tournament/{tournament_id}/roles", + request_body=Vec, + responses( + ( + status=200, description = "Roles patched successfully", + body=Vec, + example=json!(get_roles_example()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "Tournament or user not found, or the user has not been assigned any roles yet"), + (status=500, description = "Internal server error"), + ), + tag = "roles" +)] +async fn patch_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, + Json(new_roles): Json>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let modified_user = TournamentUser::get_by_id(user_id, tournament_id, pool).await?; + if modified_user.roles.is_empty() { + return Err(OmniError::ResourceNotFoundError); + } + + match Role::patch(user_id, tournament_id, new_roles, pool).await { + Ok(roles) => Ok(Json(roles).into_response()), + Err(e) => { + error!( + "Error patching roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e) + } + } +} + +/// Delete user roles within a tournament +/// This operation effectively means banning the user from a tournament. +/// Available only to the tournament Organizers and the infrastructure admin. +#[utoipa::path(delete, path = "/user/{user_id}/tournament/{tournament_id}/roles", + responses + ( + (status=204, description = "Roles deleted successfully"), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "User or tournament not found"), + (status=500, description = "Internal server error"), + ), + tag = "roles" +)] +async fn delete_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + match Role::delete(user_id, tournament_id, pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!( + "Error deleting roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +fn get_roles_example() -> String { + r#" + ["Marshall", "Judge"] + "# + .to_owned() +} + +#[test] +fn role_to_string() { + let judge = Role::Judge; + let marshall = Role::Marshall; + let organizer = Role::Organizer; + + assert!(judge.to_string() == "Judge"); + assert!(marshall.to_string() == "Marshall"); + assert!(organizer.to_string() == "Organizer") +} + +#[test] +fn role_vecs_to_string() { + let roles = Role::VARIANTS.to_vec(); + let roles_count = roles.len(); + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + for i in 0..roles_count { + assert!(roles_as_strings[i] == roles[i].to_string()) + } +} + +#[test] +fn string_to_roles() { + let role_strings = vec!["Marshall", "Judge", "Organizer", "GĆŒdacz"]; + + let marshall_role = Role::try_from(role_strings[0]).unwrap(); + let judge_role = Role::try_from(role_strings[1]).unwrap(); + let organizer_role = Role::try_from(role_strings[2]).unwrap(); + let fake_role = Role::try_from(role_strings[3]); + + assert!(marshall_role == Role::Marshall); + assert!(judge_role == Role::Judge); + assert!(organizer_role == Role::Organizer); + assert!(fake_role.is_err()); +} diff --git a/src/routes/room_routes.rs b/src/routes/room_routes.rs new file mode 100644 index 0000000..7ec7387 --- /dev/null +++ b/src/routes/room_routes.rs @@ -0,0 +1,329 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use sqlx::{query, Error, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{omni_error::OmniError, setup::AppState, tournament::{location::Location, room::{Room, RoomPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; + +const DUPLICATE_NAME_ERROR: &str = "Room with this name already exists within the scope of the tournament, to which the room is assigned."; + +pub fn route() -> Router { + Router::new() + .route("/tournament/:tournament_id/location/:location_id/room", get(get_rooms).post(create_room)) + .route( + "/tournament/:tournament_id/location/:location_id/room/:id", + get(get_room_by_id) + .patch(patch_room_by_id) + .delete(delete_room_by_id), + ) +} + +/// Create a new room +/// +/// Available only to the tournament Organizers. +#[utoipa::path(post, request_body=Room, path = "/tournament/{tournament_id}/location/{location_id}/room", + responses + ( + ( + status=200, description = "Room created successfully", + body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ), + tag="room" +)] +async fn create_room( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((tournament_id, _location_id)): Path<(Uuid, Uuid)>, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + if room_with_name_exists_in_location(&json.name, &tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match Room::post(json, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => { + error!("Error creating a new room: {e}"); + Err(e) + }, + } +} + +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{location_id}/room", + responses + ( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_rooms_list_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ), + tag="room" +)] +/// Get a list of all rooms within a location +/// +/// The user must be given a role within this tournament to use this endpoint. +async fn get_rooms( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((tournament_id, location_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadRooms) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let location = Location::get_by_id(location_id, pool).await?; + match location.get_rooms(pool).await + { + Ok(rooms) => Ok(Json(rooms).into_response()), + Err(e) => { + error!("Error getting a list of rooms: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing room +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + responses( + ( + status=200, description = "Ok", body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ), + tag="room" +)] +async fn get_room_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((tournament_id, id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadRooms) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Room::get_by_id(id, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => { + error!("Error getting a room with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing room +/// +/// Available only to the tournament Organizers. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + request_body=Room, + responses( + ( + status=200, description = "Room patched successfully", + body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + ( + status=409, + description = DUPLICATE_NAME_ERROR, + ), + (status=500, description = "Internal server error"), + ), + tag="room" +)] +async fn patch_room_by_id( + Path(( tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_room): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let room = Room::get_by_id(id, pool).await?; + let new_name = new_room.name.clone(); + if new_name.is_some() { + if room_with_name_exists_in_location(&new_name.unwrap(), &room.location_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError) + } + } + + match room.patch(new_room, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing room +/// +/// This operation is only allowed when there are no entities +/// referencing this room. Available only to the tournament Organizers. +#[utoipa::path(delete, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + responses + ( + (status=204, description = "Room deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ), + tag="room" +)] +async fn delete_room_by_id( + Path((tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Location::get_by_id(_location_id, pool).await { + Ok(_) => (), + Err(e) => { + if e.is_not_found_error() { + return Err(OmniError::ResourceNotFoundError); + } + } + } + + let room = Room::get_by_id(id, pool).await?; + match room.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a room with id {id}: {e}"); + Err(e)? + } + } +} + +async fn room_with_name_exists_in_location( + name: &String, + location_id: &Uuid, + connection_pool: &Pool, +) -> Result { + match query!( + "SELECT EXISTS(SELECT 1 FROM rooms WHERE name = $1 AND location_id = $2)", + name, + location_id + ) + .fetch_one(connection_pool) + .await + { + Ok(result) => Ok(result.exists.unwrap()), + Err(e) => Err(e), + } +} + +fn get_room_example() -> String { + r#" + { + "is_occupied": true, + "location_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Room 32", + "remarks": "Third floor" + } + "# + .to_owned() +} + +fn get_rooms_list_example() -> String { + r#" + [ + { + "is_occupied": true, + "location_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Room 32", + "remarks": "Third floor" + }, + { + "is_occupied": true, + "location_id": "77abaf34-5782-4562-b3fc-93963f66afa6", + "name": "Room 44", + "remarks": "Fourth floor" + } + ] + "# + .to_owned() +} diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 5c4ffe1..84c279b 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -3,20 +3,30 @@ use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::routes::auth; +use crate::routes::user_routes; use crate::setup::AppState; +use crate::routes::affiliation_routes; use crate::routes::attendee_routes; use crate::routes::debate_routes; +use crate::routes::location_routes; use crate::routes::motion_routes; +use crate::routes::roles_routes; +use crate::routes::room_routes; use crate::routes::team_routes; use crate::routes::tournament_routes; use crate::tournament; +use crate::tournament::affiliation; use crate::tournament::attendee; use crate::tournament::debate; +use crate::tournament::location; use crate::tournament::motion; +use crate::tournament::roles; +use crate::tournament::room; use crate::tournament::team; +use crate::users; use crate::users::permissions; -use crate::users::roles; +use crate::users::photourl; use super::health_check; use super::teapot; @@ -61,6 +71,31 @@ pub fn route() -> Router { attendee_routes::patch_attendee_by_id, attendee_routes::delete_attendee_by_id, auth::auth_login, + location_routes::create_location, + location_routes::get_locations, + location_routes::get_location_by_id, + location_routes::patch_location_by_id, + location_routes::delete_location_by_id, + room_routes::create_room, + room_routes::get_rooms, + room_routes::get_room_by_id, + room_routes::patch_room_by_id, + room_routes::delete_room_by_id, + auth::auth_clear, + user_routes::get_users, + user_routes::create_user, + user_routes::get_user_by_id, + user_routes::patch_user_by_id, + user_routes::delete_user_by_id, + roles_routes::create_user_roles, + roles_routes::get_user_roles, + roles_routes::patch_user_roles, + roles_routes::delete_user_roles, + affiliation_routes::create_affiliation, + affiliation_routes::get_affiliations, + affiliation_routes::get_affiliation_by_id, + affiliation_routes::patch_affiliation_by_id, + affiliation_routes::delete_affiliation_by_id, ), components(schemas( version::VersionDetails, @@ -79,6 +114,16 @@ pub fn route() -> Router { permissions::Permission, roles::Role, auth::LoginRequest, + location::Location, + location::LocationPatch, + room::Room, + room::RoomPatch, + users::UserWithPassword, + users::UserPatch, + users::User, + photourl::PhotoUrl, + affiliation::Affiliation, + affiliation::AffiliationPatch, )) )] diff --git a/src/routes/team_routes.rs b/src/routes/team_routes.rs index 1a8a712..a1f2fa2 100644 --- a/src/routes/team_routes.rs +++ b/src/routes/team_routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::{query, query_as, Error, Pool, Postgres}; +use sqlx::{query, Error, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -46,7 +46,8 @@ pub fn route() -> Router { ), (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] async fn create_team( State(state): State, @@ -94,7 +95,8 @@ async fn create_team( ), (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] /// Get a list of all teams /// @@ -143,6 +145,7 @@ async fn get_teams( (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), ), + tag="team" )] async fn get_team_by_id( State(state): State, @@ -192,7 +195,8 @@ async fn get_team_by_id( description = DUPLICATE_NAME_ERROR, ), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] async fn patch_team_by_id( Path(id): Path, @@ -226,7 +230,7 @@ async fn patch_team_by_id( /// /// This operation is only allowed when there are no entities /// referencing this team. Available only to the tournament Organizers. -#[utoipa::path(delete, path = "/team/{id}", +#[utoipa::path(delete, path = "/tournament/{tournament_id}/team/{id}", responses ( (status=204, description = "Team deleted successfully"), @@ -238,6 +242,7 @@ async fn patch_team_by_id( ), (status=404, description = "Tournament or team not found"), ), + tag="team" )] async fn delete_team_by_id( Path(id): Path, diff --git a/src/routes/tournament_routes.rs b/src/routes/tournament_routes.rs index 059be65..f60c085 100644 --- a/src/routes/tournament_routes.rs +++ b/src/routes/tournament_routes.rs @@ -34,12 +34,15 @@ pub fn route() -> Router { example=json!(get_tournaments_list_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to list any tournaments, meaning they do not have any roles within any tournament." ), (status=500, description = "Internal server error") -))] + ), + tag="tournament" +)] async fn get_tournaments( State(state): State, headers: HeaderMap, @@ -83,13 +86,15 @@ async fn get_tournaments( example=json!(get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify this tournament" ), (status=404, description = "Tournament not found"), (status=500, description = "Internal server error") - ) + ), + tag="tournament" )] async fn create_tournament( State(state): State, @@ -100,7 +105,7 @@ async fn create_tournament( let pool = &state.connection_pool; let user = User::authenticate(&headers, cookies, &pool).await?; if !user.is_infrastructure_admin() { - return Err(OmniError::UnauthorizedError); + return Err(OmniError::InsufficientPermissionsError); } let tournament = Tournament::post(json, pool).await?; @@ -119,13 +124,15 @@ async fn create_tournament( (get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read this tournament" ), (status=404, description = "Tournament not found"), (status=500, description = "Internal server error") ), + tag="tournament" )] async fn get_tournament_by_id( Path(id): Path, @@ -140,7 +147,7 @@ async fn get_tournament_by_id( match tournament_user.has_permission(Permission::ReadTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Tournament::get_by_id(id, pool).await { Ok(tournament) => Ok(Json(tournament).into_response()), @@ -160,14 +167,16 @@ async fn get_tournament_by_id( example=json!(get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify this tournament" ), (status=404, description = "Tournament not found"), (status=409, description = "A tournament with this name already exists"), (status=500, description = "Internal server error") - ) + ), + tag="tournament" )] async fn patch_tournament_by_id( Path(id): Path, @@ -183,7 +192,7 @@ async fn patch_tournament_by_id( match tournament_user.has_permission(Permission::WriteTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let tournament = Tournament::get_by_id(id, pool).await?; @@ -206,10 +215,12 @@ async fn patch_tournament_by_id( responses( (status=204, description = "Tournament deleted successfully"), (status=400, description = "Bad request"), - (status=401, description = "The user is not permitted to modify this tournament"), + (status=401, description = "Authentication error"), + (status=403, description = "The user is not permitted to modify this tournament"), (status=404, description = "Tournament not found"), (status=409, description = "Other resources reference this tournament. They must be deleted first") ), + tag="tournament" )] async fn delete_tournament_by_id( Path(id): Path, @@ -224,7 +235,7 @@ async fn delete_tournament_by_id( match tournament_user.has_permission(Permission::WriteTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let tournament = Tournament::get_by_id(id, pool).await?; diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs new file mode 100644 index 0000000..2ff0598 --- /dev/null +++ b/src/routes/user_routes.rs @@ -0,0 +1,312 @@ +use crate::{omni_error::OmniError, setup::AppState, users::{auth::crypto::{generate_token, hash_token}, User, UserPatch, UserWithPassword}}; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use sqlx::query; +use tower_cookies::Cookies; +use tracing::error; +use tracing_subscriber::fmt::format; +use uuid::Uuid; + +pub fn route() -> Router { + Router::new() + .route("/user", get(get_users).post(create_user)) + .route( + "/user/:id", + get(get_user_by_id) + .delete(delete_user_by_id) + .patch(patch_user_by_id), + ).route("/user/:id/login_token", post(generate_login_token)) +} + +/// Get a list of all users +/// +/// This request only returns the users the user is permitted to see. +/// The user must be given any role within a user to see it. +#[utoipa::path(get, path = "/user", + responses( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_users_list_example()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "Authentication error" + ), + (status=500, description = "Internal server error") +))] +async fn get_users( + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + User::authenticate(&headers, cookies, pool).await?; + + match User::get_all(pool).await { + Ok(users) => Ok(Json(users).into_response()), + Err(e) => { + error!("Error listing users: {e}"); + Err(e)? + } + } +} + +/// Create a new user +/// +/// Available to the infrastructure admin and tournament Organizers. +#[utoipa::path( + post, + request_body=User, + path = "/user", + responses + ( + ( + status=200, + description = "User created successfully", + body=User, + example=json!(get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to create users" + ), + (status=404, description = "User not found"), + (status=422, description = "Invalid picture link"), + (status=500, description = "Internal server error") + ) +)] +async fn create_user( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let user = User::authenticate(&headers, cookies, &pool).await?; + if !user.is_infrastructure_admin() && !user.is_organizer_of_any_tournament(pool).await? { + return Err(OmniError::InsufficientPermissionsError); + } + + let user_without_password = User::from(json.clone()); + match User::post(user_without_password, json.password, pool).await { + Ok(user) => Ok(Json(user).into_response()), + Err(e) => { + error!("Error creating a new user: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing user +/// +/// Every user is permitted to use this endpoint. +#[utoipa::path(get, path = "/user/{id}", + responses + ( + ( + status=200, description = "Ok", body=User, + example=json! + (get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "Authentication error" + ), + (status=404, description = "User not found"), + (status=500, description = "Internal server error") + ), +)] +async fn get_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + User::authenticate(&headers, cookies, pool).await?; + + match User::get_by_id(id, pool).await { + Ok(user) => Ok(Json(user).into_response()), + Err(e) => { + error!("Error getting a user with id {}: {e}", id); + Err(e) + } + } +} + +/// Patch an existing user +/// +/// Available to the infrastructure admin and the user modifying their own account. +#[utoipa::path(patch, path = "/user/{id}", + request_body=UserPatch, + responses( + ( + status=200, description = "User patched successfully", + body=User, + example=json!(get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify this user" + ), + (status=404, description = "User not found"), + (status=409, description = "A user with this name already exists"), + (status=422, description = "Invalid picture link"), + (status=500, description = "Internal server error") + ) +)] +async fn patch_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_user): Json, +) -> Result { + let pool = &state.connection_pool; + let requesting_user = + User::authenticate(&headers, cookies, &pool).await?; + + let user_to_be_patched = User::get_by_id(id, pool).await?; + + match requesting_user.is_infrastructure_admin() || requesting_user.id == user_to_be_patched.id { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match user_to_be_patched.patch(new_user, pool).await { + Ok(patched_user) => Ok(Json(patched_user).into_response()), + Err(e) => { + error!("Error patching a user with id {}: {e}", id); + Err(e)? + } + } +} + + +/// Delete an existing user. +/// +/// Available only to the infrastructure admin, +/// who's account cannot be deleted. +/// Deleted user is automatically logged out of all sessions. +/// This operation is only allowed when there are no resources +/// referencing this user. +#[utoipa::path(delete, path = "/user/{id}", + responses( + (status=204, description = "User deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "The user is not permitted to delete this user"), + (status=404, description = "User not found"), + (status=409, description = "Other resources reference this user. They must be deleted first") + ), +)] +async fn delete_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let requesting_user = + User::authenticate(&headers, cookies, pool).await?; + + match requesting_user.is_infrastructure_admin() { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let user_to_be_deleted = User::get_by_id(id, pool).await?; + + match user_to_be_deleted.is_infrastructure_admin() { + true => return Err(OmniError::InsufficientPermissionsError), + false => () + } + + user_to_be_deleted.invalidate_all_sessions(pool).await?; + match user_to_be_deleted.delete(pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => + { + if e.is_sqlx_foreign_key_violation() { + return Err(OmniError::DependentResourcesError) + } + else { + error!("Error deleting a user with id {id}: {e}"); + return Err(e)?; + } + }, + } +} + +/// Generate a single-use login token. +/// +/// Available only to the infrastructure admin. +#[utoipa::path(delete, path = "/user/{id}/login_link", + responses( + (status=200, description = "A single-use login link"), + (status=400, description = "Bad request"), + (status=401, description = "The user is not permitted to delete this user"), + (status=404, description = "User not found"), + (status=409, description = "Other resources reference this user. They must be deleted first") + ), + tag="user" +)] +async fn generate_login_token( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let user = User::authenticate(&headers, cookies, pool).await?; + if !(user.is_infrastructure_admin()) { + return Err(OmniError::InsufficientPermissionsError) + } + let token = generate_token(); + query!(r#" + INSERT INTO login_tokens (id, token_hash, user_id, used) + VALUES ($1, $2, $3, $4)"#, + Uuid::now_v7(), + hash_token(&token), + id, + false, + ).execute(pool).await?; + Ok((StatusCode::OK, token).into_response()) +} + +fn get_user_example_with_id() -> String { + r#" + { + "id": "01941265-8b3c-733f-a6ae-075c079f2f81", + "handle": "jmanczak", + "picture_link": "https://placehold.co/128x128.png" + } + "# + .to_owned() +} + +fn get_users_list_example() -> String { + r#" + [ + { + "id": "01941265-8b3c-733f-a6ae-075c079f2f81", + "handle": "jmanczak", + "picture_link": "https://placehold.co/128x128.png" + }, + { + "id": "01941265-8b3c-733f-a6ae-091c079c2921", + "handle": "Matthew Goodman", + "picture_link": "https://placehold.co/128x128.png" + } + ] + "#.to_owned() +} diff --git a/src/tournament/affiliation.rs b/src/tournament/affiliation.rs new file mode 100644 index 0000000..5a33a5f --- /dev/null +++ b/src/tournament/affiliation.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{omni_error::OmniError, users::User}; + +use super::{roles::Role, Tournament}; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// Some Judges might be affiliated with certain teams, +/// which poses a risk of biased rulings. +/// Tournament Organizers can denote such affiliations. +/// A Judge is prevented from ruling debates wherein +/// one of the sides is a team they're affiliated with. +pub struct Affiliation { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub tournament_id: Uuid, + pub team_id: Uuid, + pub judge_user_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AffiliationPatch { + pub tournament_id: Option, + pub team_id: Option, + pub judge_user_id: Option, +} + +impl Affiliation { + pub async fn post( + affiliation: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + r#"INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id) + VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id"#, + affiliation.id, + affiliation.judge_user_id, + affiliation.team_id, + affiliation.tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(affiliation), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + "SELECT * FROM judge_team_assignments WHERE id = $1", + id + ) + .fetch_one(connection_pool) + .await + { + Ok(affiliation) => Ok(affiliation), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + patch: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query!( + "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", + patch.judge_user_id, + patch.tournament_id, + patch.team_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM judge_team_assignments WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + pub async fn validate(&self, pool: &Pool) -> Result<(), OmniError> { + let user = User::get_by_id(self.judge_user_id, pool).await?; + if !user.has_role(Role::Judge, self.tournament_id, pool).await? { + return Err(OmniError::NotAJudgeAffiliationError); + } + + let _tournament = Tournament::get_by_id(self.tournament_id, pool).await?; + + if self.already_exists(pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + Ok(()) + } + + async fn already_exists(&self, pool: &Pool) -> Result { + match query_as!(Affiliation, + "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", + self.judge_user_id, + self.tournament_id, + self.team_id + ).fetch_optional(pool).await { + Ok(result) => { + if result.is_none() { + return Ok(false); + } + else { + return Ok(true); + } + }, + Err(e) => Err(e)?, + } + } +} diff --git a/src/tournament/location.rs b/src/tournament/location.rs new file mode 100644 index 0000000..43083e4 --- /dev/null +++ b/src/tournament/location.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::omni_error::OmniError; + +use super::{room::Room, utils::get_optional_value_to_be_patched}; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// Some tournaments stretch across multiple locations. +/// This struct is intended to be a representation of a bigger location +/// (e.g. a particular school or university campus), +/// possibly containing multiple places (i.e. rooms) +/// to conduct debates at. +pub struct Location { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + /// Location name. Must be unique within a tournament. + pub name: String, + /// A field dedicated to store information about location address. + /// While contents of this field could be included in remarks, + /// its presence prompts the user to include address information. + pub address: Option, + pub remarks: Option, + pub tournament_id: Uuid, +} + +#[derive(ToSchema, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct LocationPatch { + pub name: Option, + pub address: Option, + pub remarks: Option, + pub tournament_id: Option, +} + +impl Location { + pub async fn post( + location: Location, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Location, + r#"INSERT INTO locations(id, name, address, remarks, tournament_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id, name, address, remarks, tournament_id"#, + location.id, + location.name, + location.address, + location.remarks, + location.tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(location), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!(Location, "SELECT * FROM locations WHERE id = $1", id) + .fetch_one(connection_pool) + .await + { + Ok(location) => Ok(location), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + new_location: LocationPatch, + connection_pool: &Pool, + ) -> Result { + let patch = Location { + id: self.id, + name: new_location.name.unwrap_or(self.name), + address: get_optional_value_to_be_patched(self.address, new_location.address), + remarks: get_optional_value_to_be_patched(self.remarks, new_location.remarks), + tournament_id: new_location.tournament_id.unwrap_or(self.tournament_id), + }; + match query!( + r#"UPDATE locations set name = $1, address = $2, + remarks = $3, tournament_id = $4 + WHERE id = $5"#, + patch.name, + patch.address, + patch.remarks, + patch.tournament_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM locations WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + pub async fn get_rooms(&self, pool: &Pool) -> Result, OmniError> { + match query_as!(Room, "SELECT * FROM rooms WHERE location_id = $1", self.id) + .fetch_all(pool) + .await + { + Ok(rooms) => Ok(rooms), + Err(e) => { + error!("Error getting rooms of location {}: {e}", self.id); + Err(e)? + } + } + } +} diff --git a/src/tournament/mod.rs b/src/tournament/mod.rs index 93de6f4..56b695f 100644 --- a/src/tournament/mod.rs +++ b/src/tournament/mod.rs @@ -1,5 +1,5 @@ use debate::Debate; -use motion::Motion; +use location::Location; use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; use team::Team; @@ -9,10 +9,15 @@ use uuid::Uuid; use crate::omni_error::OmniError; +pub(crate) mod affiliation; pub(crate) mod attendee; pub(crate) mod debate; +pub(crate) mod location; pub(crate) mod motion; +pub(crate) mod roles; +pub(crate) mod room; pub(crate) mod team; +pub(crate) mod utils; #[derive(Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] @@ -142,4 +147,21 @@ impl Tournament { Err(e) => Err(e)?, } } + + pub async fn get_locations( + &self, + pool: &Pool, + ) -> Result, OmniError> { + match query_as!( + Location, + "SELECT * FROM locations WHERE tournament_id = $1", + self.id + ) + .fetch_all(pool) + .await + { + Ok(locations) => Ok(locations), + Err(e) => Err(e)?, + } + } } diff --git a/src/tournament/motion.rs b/src/tournament/motion.rs index d8e0d85..f3efeab 100644 --- a/src/tournament/motion.rs +++ b/src/tournament/motion.rs @@ -7,6 +7,8 @@ use uuid::Uuid; use crate::omni_error::OmniError; +use super::utils::get_optional_value_to_be_patched; + #[derive(Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct Motion { @@ -76,7 +78,7 @@ impl Motion { let motion = Motion { id: self.id, motion: patch.motion.unwrap_or(self.motion), - adinfo: patch.adinfo, + adinfo: get_optional_value_to_be_patched(patch.adinfo, self.adinfo), }; match query!( "UPDATE motions SET motion = $1, adinfo = $2 WHERE id = $3", diff --git a/src/tournament/roles.rs b/src/tournament/roles.rs new file mode 100644 index 0000000..5010f87 --- /dev/null +++ b/src/tournament/roles.rs @@ -0,0 +1,183 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sqlx::{query, Pool, Postgres}; +use strum::VariantArray; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{omni_error::OmniError, users::permissions::Permission}; + +#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] +/// Within a tournament, users must be granted roles for their +/// permissions to be defined. Each role comes with a predefined +/// set of permissions to perform certain operations. +/// By default, a newly created user has no roles. +/// Multiple users can have the same role. +pub enum Role { + /// This role grants all possible permissions within a tournament. + Organizer, + /// Judges can submit their verdicts regarding debates they were assigned to. + Judge, + /// Marshalls are responsible for conducting debates. + /// For pragmatic reasons, they can submit verdicts on Judges' behalf. + Marshall, +} + +impl Role { + pub fn get_role_permissions(&self) -> Vec { + use Permission as P; + match self { + Role::Organizer => P::VARIANTS.to_vec(), + Role::Judge => vec![ + P::ReadAttendees, + P::ReadDebates, + P::ReadTeams, + P::ReadTournament, + P::SubmitOwnVerdictVote, + ], + Role::Marshall => vec![ + P::ReadDebates, + P::ReadAttendees, + P::ReadTeams, + P::ReadTournament, + P::SubmitVerdict, + ], + } + } + + pub async fn post( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let _ = tournament_id; + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"INSERT INTO roles(id, user_id, tournament_id, roles) + VALUES ($1, $2, $3, $4) RETURNING roles"#, + Uuid::now_v7(), + user_id, + tournament_id, + &roles_as_strings + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub fn roles_vec_to_string_array(roles: &Vec) -> Vec { + let mut string_vec = vec![]; + for role in roles { + string_vec.push(role.to_string()); + } + return string_vec; + } + + pub async fn patch( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3 + RETURNING roles"#, + &roles_as_strings, + user_id, + tournament_id + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub async fn delete( + user_id: Uuid, + tournament_id: Uuid, + pool: &Pool, + ) -> Result<(), OmniError> { + match query!( + r"DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", + user_id, + tournament_id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} + +impl TryFrom<&str> for Role { + type Error = OmniError; + + fn try_from(value: &str) -> Result { + match value { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl TryFrom for Role { + type Error = OmniError; + + fn try_from(value: String) -> Result { + match value.as_str() { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl TryFrom<&String> for Role { + type Error = OmniError; + + fn try_from(value: &String) -> Result { + match value.as_str() { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Role::Organizer => write!(f, "Organizer"), + Role::Judge => write!(f, "Judge"), + Role::Marshall => write!(f, "Marshall"), + } + } +} diff --git a/src/tournament/room.rs b/src/tournament/room.rs new file mode 100644 index 0000000..5afcf9d --- /dev/null +++ b/src/tournament/room.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::omni_error::OmniError; + +use super::utils::get_optional_value_to_be_patched; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// A debate must be held in a particular place (or Room). +/// A room must be assigned to a preexisting Location. +/// While a debate +pub struct Room { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + /// Must be unique within a location. + pub name: String, + pub remarks: Option, + pub location_id: Uuid, + pub is_occupied: bool, +} + +#[derive(ToSchema, Deserialize)] +pub struct RoomPatch { + pub name: Option, + pub remarks: Option, + pub location_id: Option, + pub is_occupied: Option, +} + +impl Room { + pub async fn post( + room: Room, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Room, + r#"INSERT INTO rooms(id, name, remarks, location_id, is_occupied) + VALUES ($1, $2, $3, $4, $5) RETURNING id, name, remarks, location_id, is_occupied"#, + room.id, + room.name, + room.remarks, + room.location_id, + room.is_occupied + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(room), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!(Room, "SELECT * FROM rooms WHERE id = $1", id) + .fetch_one(connection_pool) + .await + { + Ok(room) => Ok(room), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + new_room: RoomPatch, + connection_pool: &Pool, + ) -> Result { + let patch = Room { + id: self.id, + name: new_room.name.unwrap_or(self.name), + remarks: get_optional_value_to_be_patched(self.remarks, new_room.remarks), + location_id: new_room.location_id.unwrap_or(self.location_id), + is_occupied: new_room.is_occupied.unwrap_or(self.is_occupied), + }; + match query!( + r#"UPDATE rooms set name = $1, + remarks = $2, location_id = $3 + WHERE id = $4"#, + patch.name, + patch.remarks, + patch.location_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM rooms WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} diff --git a/src/tournament/utils.rs b/src/tournament/utils.rs new file mode 100644 index 0000000..cb3b1a3 --- /dev/null +++ b/src/tournament/utils.rs @@ -0,0 +1,10 @@ +pub fn get_optional_value_to_be_patched( + old_value: Option, + new_value: Option, +) -> Option { + if new_value.is_some() { + new_value + } else { + old_value + } +} diff --git a/src/users/auth/error.rs b/src/users/auth/error.rs index 489c620..6d59845 100644 --- a/src/users/auth/error.rs +++ b/src/users/auth/error.rs @@ -19,15 +19,24 @@ pub enum AuthError { UnsupportedHeaderAuthScheme, #[error("Can only clear session given in Bearer scheme.")] ClearSessionBearerOnly, + #[error("Provided single-use login token has already been used.")] + TokenAlreadyUsed, + #[error("Provided single-use login token has expired.")] + TokenExpired, + #[error("Invalid token.")] + InvalidToken, } impl AuthError { pub fn status_code(&self) -> StatusCode { use AuthError as E; match self { - E::InvalidCredentials | E::NoCredentials | E::SessionExpired => { - StatusCode::UNAUTHORIZED - } + E::InvalidCredentials + | E::NoCredentials + | E::SessionExpired + | E::TokenAlreadyUsed + | E::TokenExpired + | E::InvalidToken => StatusCode::UNAUTHORIZED, E::NonAsciiHeaderCharacters | E::NoBasicAuthColonSplit | E::BadHeaderAuthSchemeData diff --git a/src/users/auth/login_tokens.rs b/src/users/auth/login_tokens.rs new file mode 100644 index 0000000..377170b --- /dev/null +++ b/src/users/auth/login_tokens.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use sqlx::{query, Pool, Postgres}; +use tracing::error; +use uuid::Uuid; + +use crate::omni_error::OmniError; + +pub struct LoginToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expiry: DateTime, + pub used: bool, +} + +impl LoginToken { + pub fn expired(&self) -> bool { + return &Utc::now() > &self.expiry; + } +} + +impl LoginToken { + pub async fn mark_as_used(&self, pool: &Pool) -> Result<(), OmniError> { + match query!("UPDATE login_tokens SET used = true WHERE id = $1", self.id) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => { + error!("Error invalidating token {}: {e}", self.id); + Err(e)? + } + } + } +} diff --git a/src/users/auth/mod.rs b/src/users/auth/mod.rs index b2605fe..fb61d88 100644 --- a/src/users/auth/mod.rs +++ b/src/users/auth/mod.rs @@ -4,6 +4,7 @@ use tower_cookies::cookie::time::Duration as CookieDuration; pub mod cookie; pub mod crypto; pub mod error; +mod login_tokens; pub mod session; pub mod userimpl; diff --git a/src/users/auth/userimpl.rs b/src/users/auth/userimpl.rs index 07f8fa2..d30eed3 100644 --- a/src/users/auth/userimpl.rs +++ b/src/users/auth/userimpl.rs @@ -2,7 +2,10 @@ use super::{ cookie::set_session_token_cookie, crypto::hash_token, error::AuthError, session::Session, AUTH_SESSION_COOKIE_NAME, }; -use crate::{omni_error::OmniError, users::User}; +use crate::{ + omni_error::OmniError, + users::{auth::login_tokens::LoginToken, User}, +}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use axum::http::{header::AUTHORIZATION, HeaderMap}; use base64::{prelude::BASE64_STANDARD, Engine}; @@ -118,4 +121,32 @@ impl User { }, } } + + pub async fn auth_via_link( + token: &str, + pool: &Pool, + ) -> Result { + let hashed_token = hash_token(token); + let token_record = sqlx::query_as!( + LoginToken, + "SELECT * FROM login_tokens WHERE token_hash = $1", + hashed_token + ) + .fetch_optional(pool) + .await?; + if token_record.is_none() { + return Err(AuthError::InvalidToken)?; + } + let token = token_record.unwrap(); + if token.expired() { + return Err(AuthError::TokenExpired)?; + } else if token.used { + return Err(AuthError::TokenAlreadyUsed)?; + } + token.mark_as_used(pool).await?; + match User::get_by_id(token.user_id, pool).await { + Ok(user) => Ok(user), + Err(e) => Err(e), + } + } } diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 2cbfff9..0823f9f 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -1,7 +1,8 @@ use super::User; use crate::{ omni_error::OmniError, - users::{permissions::Permission, roles::Role, TournamentUser}, + tournament::roles::Role, + users::{permissions::Permission, TournamentUser}, }; use sqlx::{Pool, Postgres}; use strum::VariantArray; @@ -17,7 +18,7 @@ impl User { User { id: Uuid::max(), handle: String::from("admin"), - profile_picture: None, + picture_link: None, } } } @@ -30,7 +31,7 @@ pub async fn guarantee_infrastructure_admin_exists(pool: &Pool) { Ok(Some(_)) => (), Ok(None) => { let admin = User::new_infrastructure_admin(); - match User::create(admin, "admin".to_string(), pool).await { + match User::post(admin, "admin".to_string(), pool).await { Ok(_) => info!("Infrastructure admin created."), Err(e) => { let err = OmniError::from(e); diff --git a/src/users/mod.rs b/src/users/mod.rs index b33639a..1291f3b 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,26 +1,28 @@ use axum::http::HeaderMap; use permissions::Permission; use photourl::PhotoUrl; -use roles::Role; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; +use utoipa::ToSchema; use uuid::Uuid; -use crate::omni_error::OmniError; +use crate::{omni_error::OmniError, tournament::roles::Role}; pub mod auth; pub mod infradmin; pub mod permissions; pub mod photourl; pub mod queries; -pub mod roles; -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct User { pub id: Uuid, + /// User handle used to log in and presented to other users. + /// Must be unique. pub handle: String, - pub profile_picture: Option, + /// A link to a profile picture. Accepted extensions are: png, jpg, jpeg, and webp. + pub picture_link: Option, } pub struct TournamentUser { @@ -28,6 +30,33 @@ pub struct TournamentUser { pub roles: Vec, } +#[derive(Deserialize, ToSchema)] +pub struct UserPatch { + pub handle: Option, + pub picture_link: Option, + pub password: Option, +} + +#[derive(Clone, Deserialize, ToSchema)] +pub struct UserWithPassword { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub handle: String, + pub picture_link: Option, + pub password: String, +} + +impl From for User { + fn from(value: UserWithPassword) -> Self { + User { + id: value.id, + handle: value.handle, + picture_link: value.picture_link, + } + } +} + impl TournamentUser { pub async fn authenticate( tournament_id: Uuid, @@ -63,9 +92,7 @@ fn construct_tournament_user() { user: User { id: Uuid::now_v7(), handle: String::from("some_org"), - profile_picture: Some( - PhotoUrl::new("https://i.imgur.com/hbrb2U0.png").unwrap(), - ), + picture_link: Some(PhotoUrl::new("https://i.imgur.com/hbrb2U0.png").unwrap()), }, roles: vec![Role::Organizer, Role::Judge, Role::Marshall], }; diff --git a/src/users/permissions.rs b/src/users/permissions.rs index df9044d..bbb1e26 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -29,4 +29,15 @@ pub enum Permission { SubmitOwnVerdictVote, SubmitVerdict, + + ReadLocations, + WriteLocations, + + ReadRooms, + ModifyAllRoomDetails, + ChangeRoomOccupationStatus, + WriteRoles, + + ReadAffiliations, + WriteAffiliations, } diff --git a/src/users/photourl.rs b/src/users/photourl.rs index 3527c99..12d884a 100644 --- a/src/users/photourl.rs +++ b/src/users/photourl.rs @@ -1,18 +1,21 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::error::Error; use url::Url; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, Deserialize, ToSchema)] +#[serde(try_from = "String", into = "String")] pub struct PhotoUrl { url: Url, } +/// A type for storing links to photo URLs. When constructed, the link is automatically validated. impl PhotoUrl { pub fn new(str: &str) -> Result { let url = Url::parse(str).map_err(PhotoUrlError::InvalidUrl)?; if PhotoUrl::has_valid_extension(&url) { - Ok(Self { url }) + Ok(PhotoUrl { url }) } else { Err(PhotoUrlError::InvalidUrlExtension) } @@ -22,6 +25,10 @@ impl PhotoUrl { &self.url } + pub fn as_str(&self) -> &str { + self.url.as_str() + } + fn has_valid_extension(url: &Url) -> bool { let path = url.path(); if let Some(filename) = path.split("/").last() { @@ -39,6 +46,20 @@ impl PhotoUrl { } } +impl TryFrom for PhotoUrl { + type Error = PhotoUrlError; + + fn try_from(value: String) -> Result { + PhotoUrl::new(&value) + } +} + +impl Into for PhotoUrl { + fn into(self) -> String { + self.as_str().to_owned() + } +} + #[derive(Debug)] pub enum PhotoUrlError { InvalidUrl(url::ParseError), @@ -58,9 +79,13 @@ impl std::fmt::Display for PhotoUrlError { impl Error for PhotoUrlError {} -#[test] -fn valid_extension_test() { - let expect_false = vec![ +#[cfg(test)] +mod tests { + use url::Url; + + use crate::users::photourl::{PhotoUrl, PhotoUrlError}; + + const EXPECT_FALSE: [&str; 10] = [ "https://manczak.net", "unix://hello.net/apng", "unix://hello.net/ajpg", @@ -72,20 +97,42 @@ fn valid_extension_test() { "unix://hello.net/a/.jpg", "unix://hello.net/a./jpg", ]; - for url in expect_false { - let url = Url::parse(url).unwrap(); - assert!(PhotoUrl::has_valid_extension(&url) == false); - } - let expect_true = vec![ + + const EXPECT_TRUE: [&str; 7] = [ "https://manczak.net/jmanczak.png", "https://manczak.net/jmanczak.jpg", "https://manczak.net/jmanczak.jpeg", "unix://hello.net/a.jpeg", "unix://hello.net/a.jpg", "unix://hello.net/a.png", + "https://placehold.co/128x128.png", ]; - for url in expect_true { - let url = Url::parse(url).unwrap(); - assert!(PhotoUrl::has_valid_extension(&url) == true); + + #[test] + fn valid_extension_test() { + for url in EXPECT_FALSE { + let url = Url::parse(url).unwrap(); + assert!(PhotoUrl::has_valid_extension(&url) == false); + } + for url in EXPECT_TRUE { + let url = Url::parse(url).unwrap(); + assert!(PhotoUrl::has_valid_extension(&url) == true); + } + } + + #[test] + fn photo_url_deserialization() { + for url in EXPECT_TRUE { + let str = format!("\"{url}\""); + let _json: PhotoUrl = serde_json::from_str(&str).unwrap(); + } + } + + #[test] + fn photo_bad_url_deserialization() { + for url in EXPECT_FALSE { + let json: Result = serde_json::from_str(url); + assert!(json.is_err()); + } } } diff --git a/src/users/queries.rs b/src/users/queries.rs index 8fcf84d..6cb3bc3 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -1,24 +1,25 @@ -use super::{photourl::PhotoUrl, roles::Role, TournamentUser, User}; -use crate::omni_error::OmniError; +use super::{photourl::PhotoUrl, TournamentUser, User, UserPatch}; +use crate::{ + omni_error::OmniError, + tournament::{roles::Role, Tournament}, +}; use argon2::{ password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHasher, }; -use serde_json::Error as JsonError; -use sqlx::{Pool, Postgres}; +use sqlx::{query, Pool, Postgres}; use uuid::Uuid; impl User { pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { - let user = - sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) - .fetch_one(pool) - .await?; + let user = query!("SELECT handle, picture_link FROM users WHERE id = $1", id) + .fetch_one(pool) + .await?; Ok(User { id, handle: user.handle, - profile_picture: match user.picture_link { + picture_link: match user.picture_link { Some(url) => Some(PhotoUrl::new(&url)?), None => None, }, @@ -28,7 +29,7 @@ impl User { handle: &str, pool: &Pool, ) -> Result { - let user = sqlx::query!( + let user = query!( "SELECT id, picture_link FROM users WHERE handle = $1", handle ) @@ -38,14 +39,14 @@ impl User { Ok(User { id: user.id, handle: handle.to_string(), - profile_picture: match user.picture_link { + picture_link: match user.picture_link { Some(url) => Some(PhotoUrl::new(&url)?), None => None, }, }) } pub async fn get_all(pool: &Pool) -> Result, OmniError> { - let users = sqlx::query!("SELECT id, handle, picture_link FROM users") + let users = query!("SELECT id, handle, picture_link FROM users") .fetch_all(pool) .await? .iter() @@ -53,7 +54,7 @@ impl User { Ok(User { id: u.id, handle: u.handle.clone(), - profile_picture: match u.picture_link.clone() { + picture_link: match u.picture_link.clone() { Some(url) => Some(PhotoUrl::new(&url)?), None => None, }, @@ -62,12 +63,12 @@ impl User { .collect::, OmniError>>()?; Ok(users) } - pub async fn create( + pub async fn post( user: User, pass: String, pool: &Pool, ) -> Result { - let pic = match &user.profile_picture { + let pic = match &user.picture_link { Some(url) => Some(url.as_url().to_string()), None => None, }; @@ -79,7 +80,7 @@ impl User { Err(e) => return Err(e)?, } }; - match sqlx::query!( + match query!( "INSERT INTO users VALUES ($1, $2, $3, $4)", &user.id, &user.handle, @@ -96,13 +97,13 @@ impl User { // ---------- DATABASE HELPERS ---------- pub async fn get_roles( &self, - tournament: Uuid, + tournament_id: Uuid, pool: &Pool, ) -> Result, OmniError> { let roles_result = sqlx::query!( "SELECT roles FROM roles WHERE user_id = $1 AND tournament_id = $2", self.id, - tournament + tournament_id ) .fetch_optional(pool) .await?; @@ -112,15 +113,144 @@ impl User { } let roles = roles_result.unwrap().roles; - let vec = match roles { - Some(vec) => vec - .iter() - .map(|role| serde_json::from_str(role.as_str())) - .collect::, JsonError>>()?, - None => vec![], + let mut parsed_roles: Vec = vec![]; + match roles { + Some(vec) => { + for value in vec { + parsed_roles.push(Role::try_from(value)?); + } + return Ok(parsed_roles); + } + None => return Ok(vec![]), + } + } + + pub async fn has_role( + &self, + role: Role, + tournament_id: Uuid, + pool: &Pool, + ) -> Result { + let roles = self.get_roles(tournament_id, pool).await?; + return Ok(roles.contains(&role)); + } + + pub async fn patch( + self, + patch: UserPatch, + pool: &Pool, + ) -> Result { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.clone()), + None => self.picture_link.clone(), + }; + let updated_user = User { + id: self.id, + handle: patch.handle.clone().unwrap_or(self.handle.clone()), + picture_link, + }; + if patch.password != None { + self.update_user_and_change_password(&patch, pool).await?; + } + self.update_user_without_changing_password(&patch, pool) + .await?; + Ok(updated_user) + } + + async fn update_user_and_change_password( + &self, + patch: &UserPatch, + pool: &Pool, + ) -> Result<(), OmniError> { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.as_url().to_string()), + None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), + }; + let password_hash = + User::generate_password_hash(&patch.password.as_ref().unwrap()) + .unwrap() + .clone(); + match query!("UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", + patch.handle, + picture_link, + password_hash, + self.id + ).execute(pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + fn generate_password_hash(password: &str) -> Result { + let hash = { + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + match argon.hash_password(password.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(e) => return Err(e)?, + } + }; + Ok(hash) + } + + async fn update_user_without_changing_password( + &self, + patch: &UserPatch, + pool: &Pool, + ) -> Result<(), OmniError> { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.as_url().to_string()), + None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), }; + match query!( + "UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", + patch.handle, + picture_link, + self.id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM users WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } - Ok(vec) + pub async fn is_organizer_of_any_tournament( + &self, + pool: &Pool, + ) -> Result { + let tournaments = Tournament::get_all(pool).await?; + for tournament in tournaments { + let roles = self.get_roles(tournament.id, pool).await?; + if roles.contains(&Role::Organizer) { + return Ok(true); + } + } + return Ok(false); + } + + pub async fn invalidate_all_sessions( + &self, + pool: &Pool, + ) -> Result<(), OmniError> { + match query!("DELETE FROM sessions WHERE user_id = $1", self.id) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } } } diff --git a/src/users/roles.rs b/src/users/roles.rs deleted file mode 100644 index 5763b5e..0000000 --- a/src/users/roles.rs +++ /dev/null @@ -1,39 +0,0 @@ -use serde::Deserialize; -use strum::VariantArray; -use utoipa::ToSchema; - -use super::permissions::Permission; - -#[derive(Debug, PartialEq, Deserialize, ToSchema)] -/// Within a tournament, users must be granted roles for their -/// permissions to be defined. Each role comes with a predefined -/// set of permissions to perform certain operations. -/// By default, a newly created user has no roles. -pub enum Role { - Organizer, - Judge, - Marshall, -} - -impl Role { - pub fn get_role_permissions(&self) -> Vec { - use Permission as P; - match self { - Role::Organizer => P::VARIANTS.to_vec(), - Role::Judge => vec![ - P::ReadAttendees, - P::ReadDebates, - P::ReadTeams, - P::ReadTournament, - P::SubmitOwnVerdictVote, - ], - Role::Marshall => vec![ - P::ReadDebates, - P::ReadAttendees, - P::ReadTeams, - P::ReadTournament, - P::SubmitVerdict, - ], - } - } -}