diff --git a/Cargo.lock b/Cargo.lock index 3dcb55819..2a06cad97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,7 @@ dependencies = [ "actix-web", "async-trait", "base64 0.13.0", + "bitflags", "chrono", "dotenv", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 60368f95e..d1d2c3aee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] } rand = "0.7.3" base64 = "0.13.0" sha1 = { version = "0.6.0", features = ["std"] } +bitflags = "1.2.1" gumdrop = "0.8.0" dotenv = "0.15" diff --git a/migrations/20201109200208_edit-teams.sql b/migrations/20201109200208_edit-teams.sql new file mode 100644 index 000000000..102d38e3e --- /dev/null +++ b/migrations/20201109200208_edit-teams.sql @@ -0,0 +1,5 @@ +-- Add migration script here +ALTER TABLE team_members +ADD COLUMN permissions bigint default 0 NOT NULL; +ALTER TABLE team_members +ADD COLUMN accepted boolean default false NOT NULL; \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index e85c875da..b791ea04b 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,25 +1,5 @@ { "db": "PostgreSQL", - "02d8895627dfe108735a6e10ad63239348b71b3322b4734526a2646f17aedf05": { - "query": "\n SELECT status FROM statuses\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "status", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int4" - ] - }, - "nullable": [ - false - ] - } - }, "03209c5bda2d704e688439919a7b3903db6ad7caebf7ddafb3ea52d312d47bfb": { "query": "\n INSERT INTO users (\n id, github_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n ", "describe": { @@ -39,6 +19,47 @@ "nullable": [] } }, + "0739834cfbef869855ed4e1aea7e1f7601f6519867ee48c573ee901c4498e04c": { + "query": "\n UPDATE team_members\n SET permissions = $1\n WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, + "07e72d55c2f18744bbfffee9920866d4aacd680f316058ec734735c173a7f16b": { + "query": "\n SELECT DISTINCT gv.version, gv.created FROM versions\n INNER JOIN game_versions_versions gvv ON gvv.joining_version_id=versions.id\n INNER JOIN game_versions gv ON gvv.game_version_id=gv.id\n WHERE versions.mod_id = $1\n ORDER BY gv.created ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + } + }, "0ca11a32b2860e4f5c3d20892a5be3cb419e084f42ba0f98e09b9995027fcc4e": { "query": "\n SELECT id FROM statuses\n WHERE status = $1\n ", "describe": { @@ -194,22 +215,6 @@ ] } }, - "15b2a2f1bbbbab4f1d99e5e428b2ffba77c83814b936fa6e10e2703b207f6e9a": { - "query": "\n INSERT INTO team_members (id, team_id, user_id, member_name, role)\n VALUES ($1, $2, $3, $4, $5)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - } - }, "17e6d30c3693e9bd9f772f3dc4e2eafe75fdeecfdcf2746eac641f77ced6b8a8": { "query": "\n SELECT u.id, u.github_id, u.name, u.email,\n u.avatar_url, u.username, u.bio,\n u.created, u.role FROM users u\n WHERE u.id IN (SELECT * FROM UNNEST($1::bigint[]))\n ", "describe": { @@ -384,6 +389,56 @@ ] } }, + "21d268dbad5ffd34d476998aea4475cdb071e8cfbb245c4853ee5f4c44b0c8ae": { + "query": "\n SELECT id, user_id, member_name, role, permissions, accepted\n FROM team_members\n WHERE (team_id = $1 AND accepted = TRUE)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "accepted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + } + }, "225597042db9c2d95296ea6bbeda4e99ffc9ddfab3991c8637ac3f4749ece6f3": { "query": "\n SELECT m.id, m.title, m.description, m.downloads, m.icon_url, m.body_url, m.published, m.updated, m.team_id\n FROM mods m\n WHERE id = $1\n ", "describe": { @@ -547,6 +602,20 @@ "nullable": [] } }, + "3b52d9f68ba23d1e3764f8df9f28bcaec0741101f6afd0c7c234b7f1b91054a4": { + "query": "\n UPDATE team_members\n SET accepted = TRUE\n WHERE (team_id = $1 AND user_id = $2 AND NOT role = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, "42e072309779598d0c213280dd8052d1b4889cb24ef5204ca13b74f693b94328": { "query": "\n SELECT user_id FROM team_members tm\n INNER JOIN mods ON mods.team_id = tm.team_id\n WHERE mods.id = $1\n ", "describe": { @@ -826,6 +895,72 @@ "nullable": [] } }, + "6c2299a7b7ab22f83049bc41fb5dd380adea3579e7b00df7d16fb6747a0a7313": { + "query": "\n UPDATE team_members\n SET role = $1\n WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, + "6d8f1863579977d367784a5b457e21c841886834afe69a964de039d6f92795d4": { + "query": "\n SELECT id, user_id, member_name, role, permissions, accepted\n FROM team_members\n WHERE (team_id = $1 AND user_id = $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "accepted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + } + }, "71db1bc306ff6da3a92544e1585aa11c5627b50d95b15e794b2fa5dc838ea1a3": { "query": "\n SELECT mod_id, version_number, author_id\n FROM versions\n WHERE id = $1\n ", "describe": { @@ -920,6 +1055,56 @@ ] } }, + "733a9569fef2aa48d1d3b1b02d7fa893174d523adc57fb22995b1ea8897f3abf": { + "query": "\n SELECT id, team_id, member_name, role, permissions, accepted\n FROM team_members\n WHERE (user_id = $1 AND accepted = TRUE)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "accepted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + } + }, "73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb": { "query": "\n DELETE FROM versions WHERE id = $1\n ", "describe": { @@ -952,20 +1137,8 @@ "nullable": [] } }, - "8f706d78ac4235ea04c59e2c220a4791e1d08fdf287b783b4aaef36fd2445467": { - "query": "\n DELETE FROM loaders\n WHERE loader = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - } - }, - "91d7e437c6dfb9b95e68aca92154bd2af4de13eeb9611e08dcad2717d0c41ed9": { - "query": "\n SELECT id, user_id, member_name, role\n FROM team_members\n WHERE team_id = $1\n ", + "7bbbeecf3246a8e07ad073a07f7d057e0990a810d69ae18cec41de60b704b174": { + "query": "\n SELECT id, user_id, member_name, role, permissions, accepted\n FROM team_members\n WHERE (team_id = $1 AND user_id = $2 AND accepted = TRUE)\n ", "describe": { "columns": [ { @@ -987,14 +1160,27 @@ "ordinal": 3, "name": "role", "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "accepted", + "type_info": "Bool" } ], "parameters": { "Left": [ + "Int8", "Int8" ] }, "nullable": [ + false, + false, false, false, false, @@ -1002,6 +1188,32 @@ ] } }, + "8ba2b2c38958f1c542e514fc62ab4682f58b0b442ac1842d20625420698e34ec": { + "query": "\n DELETE FROM team_members\n WHERE (team_id = $1 AND user_id = $2 AND NOT role = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, + "8f706d78ac4235ea04c59e2c220a4791e1d08fdf287b783b4aaef36fd2445467": { + "query": "\n DELETE FROM loaders\n WHERE loader = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + } + }, "9a41d6c1d5c250df6114157edf5621a88bc336c5c628ba89182ba999e0af3ba8": { "query": "\n SELECT id, title, description, downloads,\n icon_url, body_url, published,\n updated, status,\n issues_url, source_url, wiki_url,\n team_id\n FROM mods\n WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))\n ", "describe": { @@ -1121,6 +1333,24 @@ ] } }, + "9ef0577ee4845091a0d29bad6d1130223767e6d6140cafb9013fdb428d5159dd": { + "query": "\n INSERT INTO team_members (id, team_id, user_id, member_name, role, permissions, accepted)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Varchar", + "Int8", + "Bool" + ] + }, + "nullable": [] + } + }, "a2a99a640468a9fb8f0718e5aea6740cf5b33dafd5e038c154d6a13674fa999b": { "query": "\n INSERT INTO mods (\n id, team_id, title, description, body_url,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12\n )\n ", "describe": { @@ -1262,6 +1492,32 @@ ] } }, + "ad273daadd249e93b500c339a62aac48a497ebcc15164776ad20860a4d232896": { + "query": "\n SELECT DISTINCT gv.version, gv.created FROM versions\n INNER JOIN game_versions_versions gvv ON gvv.joining_version_id=versions.id\n INNER JOIN game_versions gv ON gvv.game_version_id=gv.id\n WHERE versions.mod_id = $1\n ORDER BY gv.created ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + } + }, "b0e3d1c70b87bb54819e3fac04b684a9b857aeedb4dcb7cb400c2af0dbb12922": { "query": "\n DELETE FROM teams\n WHERE id = $1\n ", "describe": { @@ -1700,6 +1956,73 @@ ] } }, + "d99c8f5a2d8f73f6c91ac7e72e352e03e608522142aab1b569ef5eced5ec18f8": { + "query": "\n INSERT INTO team_members (\n id, user_id, member_name, role, permissions, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Int8", + "Bool" + ] + }, + "nullable": [] + } + }, + "dd16be7b85d2a4bd77eccc780d27031b964d4d346d8899b4dd0e2f47ba86d5fd": { + "query": "\n SELECT id, team_id, member_name, role, permissions, accepted\n FROM team_members\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "accepted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + } + }, "deb81673526789bca38d39e64303f61d2a63febfdfb68136e58517af9f7792bc": { "query": "\n SELECT category FROM mods_categories\n INNER JOIN categories ON joining_category_id = id\n WHERE joining_mod_id = $1\n ", "describe": { @@ -1740,6 +2063,21 @@ ] } }, + "e53013562a3a9659df5c56185b92cf9cc30ce7e409f4762be98662161af593b9": { + "query": "\n UPDATE team_members\n SET member_name = $1\n WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, "e673006d1355fa91ba5739d7cf569eec5e1ec501f7b1dc2b431f0b1c25ac07d5": { "query": "\n DELETE FROM game_versions\n WHERE version = $1\n ", "describe": { diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 2db3b5734..6056c3278 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -30,6 +30,10 @@ pub enum DatabaseError { alphanumeric characters or '_-'." )] InvalidIdentifier(String), + #[error("Invalid permissions bitflag!")] + BitflagError, + #[error("A database request failed")] + Other(String), } impl ids::ChannelId { diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs index 33f593cbf..d47a066b6 100644 --- a/src/database/models/team_item.rs +++ b/src/database/models/team_item.rs @@ -1,4 +1,5 @@ use super::ids::*; +use crate::models::teams::Permissions; pub struct TeamBuilder { pub members: Vec, @@ -7,6 +8,8 @@ pub struct TeamMemberBuilder { pub user_id: UserId, pub name: String, pub role: String, + pub permissions: Permissions, + pub accepted: bool, } impl TeamBuilder { @@ -36,18 +39,22 @@ impl TeamBuilder { user_id: member.user_id, name: member.name, role: member.role, + permissions: member.permissions, + accepted: member.accepted, }; sqlx::query!( " - INSERT INTO team_members (id, team_id, user_id, member_name, role) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO team_members (id, team_id, user_id, member_name, role, permissions, accepted) + VALUES ($1, $2, $3, $4, $5, $6, $7) ", team_member.id as TeamMemberId, team_member.team_id as TeamId, team_member.user_id as UserId, team_member.name, team_member.role, + team_member.permissions.bits() as i64, + team_member.accepted, ) .execute(&mut *transaction) .await?; @@ -72,9 +79,12 @@ pub struct TeamMember { /// The name of the user pub name: String, pub role: String, + pub permissions: Permissions, + pub accepted: bool, } impl TeamMember { + /// Lists the members of a team pub async fn get_from_team<'a, 'b, E>( id: TeamId, executor: E, @@ -86,25 +96,339 @@ impl TeamMember { let team_members = sqlx::query!( " - SELECT id, user_id, member_name, role + SELECT id, user_id, member_name, role, permissions, accepted FROM team_members - WHERE team_id = $1 + WHERE (team_id = $1 AND accepted = TRUE) ", id as TeamId, ) .fetch_many(executor) .try_filter_map(|e| async { - Ok(e.right().map(|m| TeamMember { - id: TeamMemberId(m.id), - team_id: id, - user_id: UserId(m.user_id), - name: m.member_name, - role: m.role, - })) + if let Some(m) = e.right() { + let permissions = Permissions::from_bits(m.permissions as u64); + if let Some(perms) = permissions { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: id, + user_id: UserId(m.user_id), + name: m.member_name, + role: m.role, + permissions: perms, + accepted: m.accepted, + })) + } else { + Ok(None) + } + } else { + Ok(None) + } }) .try_collect::>() .await?; Ok(team_members) } + + /// Lists the team members for a user. Does not list pending requests. + pub async fn get_from_user_public<'a, 'b, E>( + id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + let team_members = sqlx::query!( + " + SELECT id, team_id, member_name, role, permissions, accepted + FROM team_members + WHERE (user_id = $1 AND accepted = TRUE) + ", + id as UserId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { + if let Some(m) = e.right() { + let permissions = Permissions::from_bits(m.permissions as u64); + if let Some(perms) = permissions { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id: id, + name: m.member_name, + role: m.role, + permissions: perms, + accepted: m.accepted, + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + }) + .try_collect::>() + .await?; + + Ok(team_members) + } + + /// Lists the team members for a user. Includes pending requests. + pub async fn get_from_user_private<'a, 'b, E>( + id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + let team_members = sqlx::query!( + " + SELECT id, team_id, member_name, role, permissions, accepted + FROM team_members + WHERE user_id = $1 + ", + id as UserId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { + if let Some(m) = e.right() { + let permissions = Permissions::from_bits(m.permissions as u64); + if let Some(perms) = permissions { + Ok(Some(Ok(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id: id, + name: m.member_name, + role: m.role, + permissions: perms, + accepted: m.accepted, + }))) + } else { + Ok(Some(Err(super::DatabaseError::BitflagError))) + } + } else { + Ok(None) + } + }) + .try_collect::>>() + .await?; + + let team_members = team_members + .into_iter() + .collect::, super::DatabaseError>>()?; + + Ok(team_members) + } + + /// Gets a team member from a user id and team id. Does not return pending members. + pub async fn get_from_user_id<'a, 'b, E>( + id: TeamId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id, user_id, member_name, role, permissions, accepted + FROM team_members + WHERE (team_id = $1 AND user_id = $2 AND accepted = TRUE) + ", + id as TeamId, + user_id as UserId + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: id, + user_id, + name: m.member_name, + role: m.role, + permissions: Permissions::from_bits(m.permissions as u64) + .ok_or_else(|| super::DatabaseError::BitflagError)?, + accepted: m.accepted, + })) + } else { + Ok(None) + } + } + + /// Gets a team member from a user id and team id, including pending members. + pub async fn get_from_user_id_pending<'a, 'b, E>( + id: TeamId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id, user_id, member_name, role, permissions, accepted + FROM team_members + WHERE (team_id = $1 AND user_id = $2) + ", + id as TeamId, + user_id as UserId + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: id, + user_id, + name: m.member_name, + role: m.role, + permissions: Permissions::from_bits(m.permissions as u64) + .ok_or_else(|| super::DatabaseError::BitflagError)?, + accepted: m.accepted, + })) + } else { + Ok(None) + } + } + + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + sqlx::query!( + " + INSERT INTO team_members ( + id, user_id, member_name, role, permissions, accepted + ) + VALUES ( + $1, $2, $3, $4, $5, + $6 + ) + ", + self.id as TeamMemberId, + self.user_id as UserId, + self.name, + self.role, + self.permissions.bits() as i64, + self.accepted, + ) + .execute(&mut *transaction) + .await?; + + Ok(()) + } + + pub async fn delete<'a, 'b, E>( + id: TeamId, + user_id: UserId, + executor: E, + ) -> Result<(), super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use sqlx::Done; + let result = sqlx::query!( + " + DELETE FROM team_members + WHERE (team_id = $1 AND user_id = $2 AND NOT role = $3) + ", + id as TeamId, + user_id as UserId, + crate::models::teams::OWNER_ROLE, + ) + .execute(executor) + .await?; + + if result.rows_affected() != 1 { + return Err(super::DatabaseError::Other(format!( + "Deleting a member failed; {} rows deleted", + result.rows_affected() + ))); + } + + Ok(()) + } + + pub async fn edit_team_member( + id: TeamId, + user_id: UserId, + new_permissions: Option, + new_role: Option, + new_accepted: Option, + new_name: Option, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), super::DatabaseError> { + if let Some(permissions) = new_permissions { + sqlx::query!( + " + UPDATE team_members + SET permissions = $1 + WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4) + ", + permissions.bits() as i64, + id as TeamId, + user_id as UserId, + crate::models::teams::OWNER_ROLE, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(role) = new_role { + sqlx::query!( + " + UPDATE team_members + SET role = $1 + WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4) + ", + role, + id as TeamId, + user_id as UserId, + crate::models::teams::OWNER_ROLE, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(accepted) = new_accepted { + if accepted { + sqlx::query!( + " + UPDATE team_members + SET accepted = TRUE + WHERE (team_id = $1 AND user_id = $2 AND NOT role = $3) + ", + id as TeamId, + user_id as UserId, + crate::models::teams::OWNER_ROLE, + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(name) = new_name { + sqlx::query!( + " + UPDATE team_members + SET member_name = $1 + WHERE (team_id = $2 AND user_id = $3 AND NOT role = $4) + ", + name, + id as TeamId, + user_id as UserId, + crate::models::teams::OWNER_ROLE, + ) + .execute(&mut *transaction) + .await?; + } + + Ok(()) + } } diff --git a/src/models/teams.rs b/src/models/teams.rs index e1cffaad3..5b579429d 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -20,6 +20,27 @@ pub struct Team { pub members: Vec, } +bitflags::bitflags! { + #[derive(Serialize, Deserialize)] + pub struct Permissions: u64 { + const UPLOAD_VERSION = 1 << 0; + const DELETE_VERSION = 1 << 1; + const EDIT_DETAILS = 1 << 2; + const EDIT_BODY = 1 << 3; + const MANAGE_INVITES = 1 << 4; + const REMOVE_MEMBER = 1 << 5; + const EDIT_MEMBER = 1 << 6; + const DELETE_MOD = 1 << 7; + const ALL = 0b11111111; + } +} + +impl Default for Permissions { + fn default() -> Permissions { + Permissions::UPLOAD_VERSION | Permissions::DELETE_VERSION + } +} + /// A member of a team #[derive(Serialize, Deserialize, Clone)] pub struct TeamMember { @@ -29,4 +50,6 @@ pub struct TeamMember { pub name: String, /// The role of the user in the team pub role: String, + /// A bitset containing the user's permissions in this team + pub permissions: Permissions, } diff --git a/src/models/users.rs b/src/models/users.rs index b0a881c81..8f2d87e00 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -19,7 +19,7 @@ pub struct User { pub role: Role, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Role { Developer, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4f992cfd5..f3e7f7472 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -49,12 +49,20 @@ pub fn users_config(cfg: &mut web::ServiceConfig) { web::scope("user") .service(users::user_get) .service(users::mods_list) - .service(users::user_delete), + .service(users::user_delete) + .service(users::teams), ); } pub fn teams_config(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("team").service(teams::team_members_get)); + cfg.service( + web::scope("team") + .service(teams::team_members_get) + .service(teams::edit_team_member) + .service(teams::add_team_member) + .service(teams::join_team) + .service(teams::remove_team_member), + ); } #[derive(thiserror::Error, Debug)] @@ -63,8 +71,12 @@ pub enum ApiError { DatabaseError(#[from] crate::database::models::DatabaseError), #[error("Deserialization error: {0}")] JsonError(#[from] serde_json::Error), - #[error("Authentication Error")] - AuthenticationError, + #[error("Authentication Error: {0}")] + AuthenticationError(#[from] crate::auth::AuthenticationError), + #[error("Authentication Error: {0}")] + CustomAuthenticationError(String), + #[error("Invalid Input: {0}")] + InvalidInputError(String), #[error("Search Error: {0}")] SearchError(#[from] meilisearch_sdk::errors::Error), } @@ -73,9 +85,11 @@ impl actix_web::ResponseError for ApiError { fn status_code(&self) -> actix_web::http::StatusCode { match self { ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, - ApiError::AuthenticationError => actix_web::http::StatusCode::UNAUTHORIZED, + ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED, + ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED, ApiError::JsonError(..) => actix_web::http::StatusCode::BAD_REQUEST, ApiError::SearchError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + ApiError::InvalidInputError(..) => actix_web::http::StatusCode::BAD_REQUEST, } } @@ -84,9 +98,11 @@ impl actix_web::ResponseError for ApiError { crate::models::error::ApiError { error: match self { ApiError::DatabaseError(..) => "database_error", - ApiError::AuthenticationError => "unauthorized", + ApiError::AuthenticationError(..) => "unauthorized", + ApiError::CustomAuthenticationError(..) => "unauthorized", ApiError::JsonError(..) => "json_error", ApiError::SearchError(..) => "search_error", + ApiError::InvalidInputError(..) => "invalid_input", }, description: &self.to_string(), }, diff --git a/src/routes/mod_creation.rs b/src/routes/mod_creation.rs index 9d43b5a3b..d015d3560 100644 --- a/src/routes/mod_creation.rs +++ b/src/routes/mod_creation.rs @@ -437,6 +437,8 @@ async fn mod_create_inner( user_id: current_user.id.into(), name: current_user.username.clone(), role: crate::models::teams::OWNER_ROLE.to_owned(), + permissions: crate::models::teams::Permissions::ALL, + accepted: true, }], }; diff --git a/src/routes/mods.rs b/src/routes/mods.rs index c63c8fd9e..f81a09681 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs @@ -1,8 +1,10 @@ use super::ApiError; -use crate::auth::check_is_moderator_from_headers; +use crate::auth::get_user_from_headers; use crate::database; use crate::models; use crate::models::mods::SearchRequest; +use crate::models::teams::Permissions; +use crate::models::users::Role; use crate::search::{search_for_mod, SearchConfig, SearchError}; use actix_web::{delete, get, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; @@ -92,17 +94,30 @@ pub async fn mod_delete( pool: web::Data, config: web::Data, ) -> Result { - check_is_moderator_from_headers( - req.headers(), - &mut *pool - .acquire() - .await - .map_err(|e| ApiError::DatabaseError(e.into()))?, - ) - .await - .map_err(|_| ApiError::AuthenticationError)?; - + let user = get_user_from_headers(req.headers(), &**pool).await?; let id = info.into_inner().0; + + if user.role != Role::Moderator || user.role != Role::Admin { + let mod_item = database::models::Mod::get(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?; + let team_member = database::models::TeamMember::get_from_user_id( + mod_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::DatabaseError)? + .ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?; + + if !team_member.permissions.contains(Permissions::DELETE_MOD) { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to delete this mod".to_string(), + )); + } + } + let result = database::models::Mod::remove_full(id.into(), &**pool) .await .map_err(|e| ApiError::DatabaseError(e.into()))?; diff --git a/src/routes/tags.rs b/src/routes/tags.rs index 3b7ef8a73..1aae1c019 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -41,8 +41,7 @@ pub async fn category_create( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = category.into_inner().0; @@ -64,8 +63,7 @@ pub async fn category_delete( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = category.into_inner().0; let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; @@ -103,8 +101,7 @@ pub async fn loader_create( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = loader.into_inner().0; @@ -126,8 +123,7 @@ pub async fn loader_delete( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = loader.into_inner().0; let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; @@ -187,8 +183,7 @@ pub async fn game_version_create( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = game_version.into_inner().0; @@ -221,8 +216,7 @@ pub async fn game_version_delete( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let name = game_version.into_inner().0; let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; diff --git a/src/routes/teams.rs b/src/routes/teams.rs index d2e5aa8f7..551423514 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -1,25 +1,363 @@ +use crate::auth::get_user_from_headers; use crate::database::models::TeamMember; -use crate::models::teams::TeamId; +use crate::models::teams::{Permissions, TeamId}; +use crate::models::users::UserId; use crate::routes::ApiError; -use actix_web::{get, web, HttpResponse}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; #[get("{id}/members")] pub async fn team_members_get( + req: HttpRequest, info: web::Path<(TeamId,)>, pool: web::Data, ) -> Result { let id = info.into_inner().0; let members_data = TeamMember::get_from_team(id.into(), &**pool).await?; + let current_user = get_user_from_headers(req.headers(), &**pool).await.ok(); + + if let Some(user) = current_user { + let team_member = TeamMember::get_from_user_id(id.into(), user.id.into(), &**pool) + .await + .map_err(ApiError::DatabaseError)?; + + if team_member.is_some() { + let team_members: Vec = members_data + .into_iter() + .map(|data| crate::models::teams::TeamMember { + user_id: data.user_id.into(), + name: data.name, + role: data.role, + permissions: data.permissions, + }) + .collect(); + + return Ok(HttpResponse::Ok().json(team_members)); + } + } + let team_members: Vec = members_data .into_iter() .map(|data| crate::models::teams::TeamMember { user_id: data.user_id.into(), name: data.name, role: data.role, + permissions: Permissions::default(), }) .collect(); Ok(HttpResponse::Ok().json(team_members)) } + +#[post("{id}/join")] +pub async fn join_team( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, +) -> Result { + let team_id = info.into_inner().0.into(); + let current_user = get_user_from_headers(req.headers(), &**pool).await?; + + let member = + TeamMember::get_from_user_id_pending(team_id, current_user.id.into(), &**pool).await?; + + if let Some(member) = member { + if member.accepted { + return Err(ApiError::InvalidInputError( + "You are already a member of this team".to_string(), + )); + } + let mut transaction = pool + .begin() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + // Edit Team Member to set Accepted to True + TeamMember::edit_team_member( + team_id, + current_user.id.into(), + None, + None, + Some(true), + None, + &mut transaction, + ) + .await?; + + transaction + .commit() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + } else { + return Err(ApiError::InvalidInputError( + "There is no pending request from this team".to_string(), + )); + } + + Ok(HttpResponse::Ok().body("")) +} + +fn default_role() -> String { + "Member".to_string() +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NewTeamMember { + pub user_id: UserId, + #[serde(default = "default_role")] + pub role: String, + #[serde(default = "Permissions::default")] + pub permissions: Permissions, +} + +#[post("{id}/members")] +pub async fn add_team_member( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_member: web::Json, +) -> Result { + let team_id = info.into_inner().0.into(); + + let mut transaction = pool + .begin() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let current_user = get_user_from_headers(req.headers(), &**pool).await?; + let team_member = + TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?; + + let member = match team_member { + Some(m) => m, + None => { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to invite users to this team".to_string(), + )) + } + }; + + if !member.permissions.contains(Permissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to invite users to this team".to_string(), + )); + } + if !member.permissions.contains(new_member.permissions) { + return Err(ApiError::InvalidInputError( + "The new member has permissions that you don't have".to_string(), + )); + } + + if new_member.role == crate::models::teams::OWNER_ROLE { + return Err(ApiError::InvalidInputError( + "The `Owner` role is restricted to one person".to_string(), + )); + } + let request = crate::database::models::team_item::TeamMember::get_from_user_id_pending( + team_id, + member.user_id, + &**pool, + ) + .await?; + + if let Some(req) = request { + if req.accepted { + return Err(ApiError::InvalidInputError( + "The user is already a member of that team".to_string(), + )); + } else { + return Err(ApiError::InvalidInputError( + "There is already a pending member request for this user".to_string(), + )); + } + } + + let new_user = crate::database::models::User::get(member.user_id, &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .ok_or_else(|| ApiError::InvalidInputError("An invalid User ID specified".to_string()))?; + + let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?; + TeamMember { + id: new_id, + team_id, + user_id: new_member.user_id.into(), + name: new_user.username, + role: new_member.role.clone(), + permissions: new_member.permissions, + accepted: false, + } + .insert(&mut transaction) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + transaction + .commit() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + Ok(HttpResponse::Ok().body("")) +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EditTeamMember { + pub permissions: Option, + pub role: Option, + pub name: Option, +} + +#[patch("{id}/members/{user_id}")] +pub async fn edit_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + edit_member: web::Json, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers(req.headers(), &**pool).await?; + let team_member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; + + let mut transaction = pool + .begin() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let member = match team_member { + Some(m) => m, + None => { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to edit members of this team".to_string(), + )) + } + }; + + // If the only thing being modified is the name, a user can + // modify their own member without extra permissions. + if user_id == current_user.id.into() + && edit_member.permissions.is_none() + && edit_member.role.is_none() + { + TeamMember::edit_team_member( + id, + user_id, + None, + None, + None, + edit_member.name.clone(), + &mut transaction, + ) + .await?; + + transaction + .commit() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + return Ok(HttpResponse::Ok().body("")); + } + + if !member.permissions.contains(Permissions::EDIT_MEMBER) { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to edit members of this team".to_string(), + )); + } + + if let Some(new_permissions) = edit_member.permissions { + if !member.permissions.contains(new_permissions) { + return Err(ApiError::InvalidInputError( + "The new permissions have permissions that you don't have".to_string(), + )); + } + } + + if edit_member.role.as_deref() == Some(crate::models::teams::OWNER_ROLE) { + return Err(ApiError::InvalidInputError( + "The `Owner` role is restricted to one person".to_string(), + )); + } + + TeamMember::edit_team_member( + id, + user_id, + edit_member.permissions, + edit_member.role.clone(), + None, + edit_member.name.clone(), + &mut transaction, + ) + .await?; + + transaction + .commit() + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + Ok(HttpResponse::Ok().body("")) +} + +#[delete("{id}/members/{user_id}")] +pub async fn remove_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers(req.headers(), &**pool).await?; + let team_member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; + + let member = match team_member { + Some(m) => m, + None => { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to remove members from this team".to_string(), + )) + } + }; + + let delete_member = TeamMember::get_from_user_id(id, user_id, &**pool).await?; + + if let Some(delete_member) = delete_member { + if delete_member.role == crate::models::teams::OWNER_ROLE { + // The owner cannot be removed from a team + return Err(ApiError::CustomAuthenticationError( + "The owner can't be removed from a team".to_string(), + )); + } + + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if delete_member.user_id == member.user_id + || member.permissions.contains(Permissions::REMOVE_MEMBER) + { + TeamMember::delete(id, user_id, &**pool).await?; + } else { + return Err(ApiError::CustomAuthenticationError( + "You do not have permission to remove a member from this team".to_string(), + )); + } + } else if delete_member.user_id == member.user_id + || member.permissions.contains(Permissions::MANAGE_INVITES) + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &**pool).await?; + } else { + return Err(ApiError::CustomAuthenticationError( + "You do not have permission to cancel a team invite".to_string(), + )); + } + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/users.rs b/src/routes/users.rs index 6c3002a6e..bbf084b9f 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,5 +1,6 @@ use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; -use crate::database::models::User; +use crate::database::models::{TeamMember, User}; +use crate::models::teams::Permissions; use crate::models::users::{Role, UserId}; use crate::routes::ApiError; use actix_web::{delete, get, web, HttpRequest, HttpResponse}; @@ -19,8 +20,7 @@ pub async fn user_auth_get( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?, + .await?, )) } @@ -121,6 +121,47 @@ pub async fn mods_list( } } +#[get("teams")] +pub async fn teams( + req: HttpRequest, + info: web::Path<(UserId,)>, + pool: web::Data, +) -> Result { + let id: crate::database::models::UserId = info.into_inner().0.into(); + + let current_user = get_user_from_headers(req.headers(), &**pool).await.ok(); + + let results; + let mut same_user = false; + + if let Some(user) = current_user { + if user.id.0 == id.0 as u64 { + results = TeamMember::get_from_user_private(id, &**pool).await?; + same_user = true; + } else { + results = TeamMember::get_from_user_public(id, &**pool).await?; + } + } else { + results = TeamMember::get_from_user_public(id, &**pool).await?; + } + + let team_members: Vec = results + .into_iter() + .map(|data| crate::models::teams::TeamMember { + user_id: data.user_id.into(), + name: data.name, + role: data.role, + permissions: if same_user { + data.permissions + } else { + Permissions::default() + }, + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) +} + // TODO: Make this actually do stuff #[delete("{id}")] pub async fn user_delete( @@ -135,8 +176,7 @@ pub async fn user_delete( .await .map_err(|e| ApiError::DatabaseError(e.into()))?, ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + .await?; let _id = info.0; let result = Some(()); diff --git a/src/routes/versions.rs b/src/routes/versions.rs index dec9d8217..c591da1ca 100644 --- a/src/routes/versions.rs +++ b/src/routes/versions.rs @@ -1,7 +1,9 @@ use super::ApiError; -use crate::auth::check_is_moderator_from_headers; +use crate::auth::get_user_from_headers; use crate::database; use crate::models; +use crate::models::teams::Permissions; +use crate::models::users::Role; use actix_web::{delete, get, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -142,21 +144,44 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models #[delete("{version_id}")] pub async fn version_delete( req: HttpRequest, - info: web::Path<(models::ids::ModId, models::ids::VersionId)>, + info: web::Path<(models::ids::VersionId,)>, pool: web::Data, ) -> Result { - check_is_moderator_from_headers( - req.headers(), - &mut *pool - .acquire() - .await - .map_err(|e| ApiError::DatabaseError(e.into()))?, - ) - .await - .map_err(|_| ApiError::AuthenticationError)?; + let user = get_user_from_headers(req.headers(), &**pool).await?; + let id = info.into_inner().0; + + if user.role != Role::Moderator || user.role != Role::Admin { + let version = database::models::Version::get(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .ok_or_else(|| { + ApiError::InvalidInputError("Invalid Version ID specified!".to_string()) + })?; + let mod_item = database::models::Mod::get(version.mod_id, &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .ok_or_else(|| { + ApiError::InvalidInputError("Invalid Version ID specified!".to_string()) + })?; + let team_member = database::models::TeamMember::get_from_user_id( + mod_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::DatabaseError)? + .ok_or_else(|| ApiError::InvalidInputError("Invalid Version ID specified!".to_string()))?; + + if !team_member + .permissions + .contains(Permissions::DELETE_VERSION) + { + return Err(ApiError::CustomAuthenticationError( + "You don't have permission to delete versions in this team".to_string(), + )); + } + } - // TODO: check if the mod exists and matches the version id - let id = info.1; let result = database::models::Version::remove_full(id.into(), &**pool) .await .map_err(|e| ApiError::DatabaseError(e.into()))?;