summaryrefslogtreecommitdiff
path: root/server/src/main.rs
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2024-12-30 22:54:26 +0100
committerJoel Klinghed <the_jk@spawned.biz>2024-12-30 22:54:26 +0100
commit48e199eff5fca8f5e4aa71a4091d3ae7acc82b9b (patch)
tree7658a4b55b10293ead1f69e628e9c2731ce6b9f8 /server/src/main.rs
parent74538f6e3050e67bd06916a111d55933108036d2 (diff)
Add methods for modifying projects
While doing that I realized I had forgotten to declare maintainers for projects. Also added default roles and changed so that review_users only contains overrides, so that changes to the project users is instantly applied to all reviews (unless there is an override).
Diffstat (limited to 'server/src/main.rs')
-rw-r--r--server/src/main.rs448
1 files changed, 375 insertions, 73 deletions
diff --git a/server/src/main.rs b/server/src/main.rs
index 342ed53..be5bb77 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,17 +2,19 @@
extern crate rocket;
use futures::{future::TryFutureExt, stream::TryStreamExt};
-
use rocket::fairing::{self, AdHoc};
-use rocket::response::status::NotFound;
+use rocket::http::Status;
+use rocket::response::status::{Custom, NotFound};
use rocket::serde::json::Json;
use rocket::{futures, Build, Rocket};
use rocket_db_pools::{sqlx, Connection, Database};
+use sqlx::Acquire;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
mod api_model;
mod auth;
+mod db_utils;
use auth::AuthApiAddon;
@@ -22,33 +24,44 @@ struct Db(sqlx::MySqlPool);
#[derive(OpenApi)]
#[openapi(
- paths(projects, project, reviews, review,),
+ paths(
+ projects,
+ project,
+ project_new,
+ project_update,
+ project_user_add,
+ project_user_update,
+ project_user_del,
+ reviews,
+ review,
+ ),
modifiers(&AuthApiAddon),
)]
pub struct MainApi;
-enum Role {
- Reviewer,
- Watcher,
-}
-
-struct UserRole {
- user: api_model::User,
- role: Role,
-}
-
-impl TryFrom<u8> for Role {
+impl TryFrom<u8> for api_model::UserReviewRole {
type Error = &'static str;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
- 0 => Ok(Role::Reviewer),
- 1 => Ok(Role::Watcher),
+ 0 => Ok(api_model::UserReviewRole::None),
+ 1 => Ok(api_model::UserReviewRole::Reviewer),
+ 2 => Ok(api_model::UserReviewRole::Watcher),
_ => Err("Invalid role"),
}
}
}
+impl From<api_model::UserReviewRole> for u8 {
+ fn from(value: api_model::UserReviewRole) -> u8 {
+ match value {
+ api_model::UserReviewRole::None => 0,
+ api_model::UserReviewRole::Reviewer => 1,
+ api_model::UserReviewRole::Watcher => 2,
+ }
+ }
+}
+
impl TryFrom<u8> for api_model::ReviewState {
type Error = &'static str;
@@ -112,30 +125,23 @@ async fn projects(
})
}
-#[utoipa::path(
- responses(
- (status = 200, description = "Get project", body = api_model::Project),
- (status = 404, description = "No such project"),
- ),
- security(
- ("session" = []),
- ),
-)]
-#[get("/project/<projectid>")]
-async fn project(
- mut db: Connection<Db>,
- _session: auth::Session,
+async fn get_project(
+ db: &mut Connection<Db>,
projectid: u64,
) -> Result<Json<api_model::Project>, NotFound<&'static str>> {
- let members = sqlx::query!(
- "SELECT id, username, name, active FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?",
+ let users = sqlx::query!(
+ "SELECT id, username, name, active, default_role, maintainer FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?",
projectid)
- .fetch(&mut **db)
- .map_ok(|r| api_model::User {
- id: r.id,
- username: r.username,
- name: r.name,
- active: r.active != 0,
+ .fetch(&mut ***db)
+ .map_ok(|r| api_model::ProjectUserEntry {
+ user: api_model::User {
+ id: r.id,
+ username: r.username,
+ name: r.name,
+ active: r.active != 0,
+ },
+ default_role: api_model::UserReviewRole::try_from(r.default_role).unwrap(),
+ maintainer: r.maintainer != 0,
})
.try_collect::<Vec<_>>()
.await
@@ -145,12 +151,12 @@ async fn project(
"SELECT id,title,description FROM projects WHERE id=?",
projectid
)
- .fetch_one(&mut **db)
+ .fetch_one(&mut ***db)
.map_ok(|r| api_model::Project {
id: r.id,
title: r.title,
description: r.description,
- members,
+ users,
})
.map_err(|_| NotFound("No such project"))
.await?;
@@ -160,6 +166,266 @@ async fn project(
#[utoipa::path(
responses(
+ (status = 200, description = "Get project", body = api_model::Project),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[get("/project/<projectid>")]
+async fn project(
+ mut db: Connection<Db>,
+ _session: auth::Session,
+ projectid: u64,
+) -> Result<Json<api_model::Project>, NotFound<&'static str>> {
+ get_project(&mut db, projectid).await
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Project updated", body = api_model::Project),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/project/new", data = "<data>")]
+async fn project_new(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ data: Json<api_model::ProjectData<'_>>,
+) -> Json<api_model::Project> {
+ let projectid: u64;
+
+ {
+ let mut tx = db.begin().await.unwrap();
+
+ projectid = sqlx::query!(
+ "INSERT INTO projects (title, description) VALUES (?, ?)",
+ data.title.unwrap_or("Unnamed"),
+ data.description.unwrap_or("")
+ )
+ .execute(&mut *tx)
+ .map_ok(|r| r.last_insert_id())
+ .await
+ .unwrap();
+
+ sqlx::query!(
+ "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)",
+ projectid, session.user_id, u8::from(api_model::UserReviewRole::Reviewer), true)
+ .execute(&mut *tx)
+ .await
+ .unwrap();
+
+ tx.commit().await.unwrap();
+ }
+
+ get_project(&mut db, projectid).await.unwrap()
+}
+
+async fn project_check_maintainer(
+ db: &mut Connection<Db>,
+ session: auth::Session,
+ projectid: u64,
+) -> Result<&'static str, Custom<&'static str>> {
+ let is_maintainer = sqlx::query!(
+ "SELECT COUNT(user) AS count FROM project_users WHERE project=? AND user=? AND maintainer=TRUE",
+ projectid, session.user_id)
+ .fetch_one(&mut ***db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+
+ if is_maintainer == 0 {
+ // Check if it is because user is not maintainer or project just doesn't exist
+ sqlx::query!("SELECT id AS count FROM projects WHERE id=?", projectid)
+ .fetch_one(&mut ***db)
+ .map_err(|_| Custom(Status::NotFound, "No such project"))
+ .await?;
+
+ Err(Custom(Status::Unauthorized, "Not maintainer of project"))
+ } else {
+ Ok("")
+ }
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Project updated"),
+ (status = 401, description = "Not maintainer of project"),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/project/<projectid>", data = "<data>")]
+async fn project_update(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ projectid: u64,
+ data: Json<api_model::ProjectData<'_>>,
+) -> Result<&'static str, Custom<&'static str>> {
+ project_check_maintainer(&mut db, session, projectid)
+ .await
+ .unwrap();
+
+ if data.title.is_none() && data.description.is_none() {
+ // Nothing to update. Treat as "success".
+ } else {
+ let mut update_builder: db_utils::UpdateBuilder<sqlx::MySql> =
+ db_utils::UpdateBuilder::new();
+ update_builder.table("projects");
+
+ if let Some(title) = &data.title {
+ update_builder.set("title", title);
+ }
+ if let Some(description) = &data.description {
+ update_builder.set("description", description);
+ }
+ update_builder.and_where("id", "=", projectid);
+
+ let (query, args) = update_builder.build();
+
+ let mut query_builder: sqlx::QueryBuilder<sqlx::MySql> =
+ sqlx::QueryBuilder::with_arguments(query, args);
+ query_builder.build().execute(&mut **db).await.unwrap();
+ }
+
+ Ok("")
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "User added to project"),
+ (status = 401, description = "Not maintainer of project"),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/project/<projectid>/user/new?<userid>", data = "<data>")]
+async fn project_user_add(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ projectid: u64,
+ userid: u64,
+ data: Json<api_model::ProjectUserEntryData>,
+) -> Result<&'static str, Custom<&'static str>> {
+ project_check_maintainer(&mut db, session, projectid)
+ .await
+ .unwrap();
+
+ sqlx::query!(
+ "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)",
+ projectid,
+ userid,
+ u8::from(
+ data.default_role
+ .unwrap_or(api_model::UserReviewRole::Reviewer)
+ ),
+ data.maintainer.unwrap_or(false)
+ )
+ .execute(&mut **db)
+ .await
+ .unwrap();
+
+ Ok("")
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "User updated in project"),
+ (status = 401, description = "Not maintainer of project"),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/project/<projectid>/user/<userid>", data = "<data>")]
+async fn project_user_update(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ projectid: u64,
+ userid: u64,
+ data: Json<api_model::ProjectUserEntryData>,
+) -> Result<&'static str, Custom<&'static str>> {
+ let need_maintainer = data.maintainer.is_some() || userid != session.user_id;
+
+ if need_maintainer {
+ project_check_maintainer(&mut db, session, projectid)
+ .await
+ .unwrap();
+ }
+
+ if data.default_role.is_none() && data.maintainer.is_none() {
+ // Nothing to update. Treat as "success".
+ } else {
+ let mut update_builder: db_utils::UpdateBuilder<sqlx::MySql> =
+ db_utils::UpdateBuilder::new();
+ update_builder.table("project_users");
+
+ if let Some(default_role) = &data.default_role {
+ update_builder.set("default_role", u8::from(*default_role));
+ }
+ if let Some(maintainer) = &data.maintainer {
+ update_builder.set("maintainer", maintainer);
+ }
+ update_builder.and_where("project", "=", projectid);
+ update_builder.and_where("user", "=", userid);
+
+ let (query, args) = update_builder.build();
+
+ let mut query_builder: sqlx::QueryBuilder<sqlx::MySql> =
+ sqlx::QueryBuilder::with_arguments(query, args);
+ query_builder.build().execute(&mut **db).await.unwrap();
+ }
+
+ Ok("")
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "User removed from project"),
+ (status = 401, description = "Not maintainer of project"),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[delete("/project/<projectid>/user/<userid>")]
+async fn project_user_del(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ projectid: u64,
+ userid: u64,
+) -> Result<&'static str, Custom<&'static str>> {
+ let need_maintainer = userid != session.user_id;
+
+ if need_maintainer {
+ project_check_maintainer(&mut db, session, projectid)
+ .await
+ .unwrap();
+ }
+
+ sqlx::query!(
+ "DELETE FROM project_users WHERE project=? AND user=?",
+ projectid,
+ userid
+ )
+ .execute(&mut **db)
+ .await
+ .unwrap();
+
+ Ok("")
+}
+
+#[utoipa::path(
+ responses(
(status = 200, description = "Get all reviews for project", body = api_model::Reviews),
),
security(
@@ -231,57 +497,79 @@ async fn review(
_session: auth::Session,
reviewid: u64,
) -> Result<Json<api_model::Review>, NotFound<&'static str>> {
- let mut users = sqlx::query!(
- "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id",
+ let mut projectid = 0;
+
+ let mut review = sqlx::query!(
+ "SELECT reviews.id AS id,project,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?",
reviewid)
+ .fetch_one(&mut **db)
+ .map_ok(|r| {
+ projectid = r.project;
+
+ api_model::Review {
+ id: r.id,
+ title: r.title,
+ description: r.description,
+ owner: api_model::User {
+ id: r.user_id,
+ username: r.username,
+ name: r.name,
+ active: r.user_active != 0,
+ },
+ users: Vec::new(),
+ state: api_model::ReviewState::try_from(r.state).unwrap(),
+ progress: r.progress,
+ }
+ })
+ .map_err(|_| NotFound("No such review"))
+ .await?;
+
+ let mut users = sqlx::query!(
+ "SELECT id,username,name,active,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,username,id",
+ projectid)
.fetch(&mut **db)
- .map_ok(|r| UserRole {
+ .map_ok(|r| api_model::ReviewUserEntry {
user: api_model::User {
id: r.id,
username: r.username,
name: r.name,
active: r.active != 0,
},
- role: Role::try_from(r.role).unwrap(),
+ role: api_model::UserReviewRole::try_from(r.role).unwrap(),
})
.try_collect::<Vec<_>>()
.await
.unwrap();
- let first_reviewer = users
- .iter()
- .position(|u| matches!(u.role, Role::Reviewer))
- .unwrap_or(users.len());
- let mut reviewers: Vec<api_model::User> = Vec::with_capacity(first_reviewer);
- for user_role in users.drain(0..first_reviewer) {
- reviewers.push(user_role.user);
- }
- let mut watchers: Vec<api_model::User> = Vec::with_capacity(users.len());
- for user_role in users.drain(0..) {
- watchers.push(user_role.user);
- }
-
- let review = sqlx::query!(
- "SELECT reviews.id AS id,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?",
+ let override_users = sqlx::query!(
+ "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id",
reviewid)
- .fetch_one(&mut **db)
- .map_ok(|r| api_model::Review {
- id: r.id,
- title: r.title,
- description: r.description,
- owner: api_model::User {
- id: r.user_id,
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::ReviewUserEntry {
+ user: api_model::User {
+ id: r.id,
username: r.username,
name: r.name,
- active: r.user_active != 0,
+ active: r.active != 0,
},
- reviewers,
- watchers,
- state: api_model::ReviewState::try_from(r.state).unwrap(),
- progress: r.progress,
+ role: api_model::UserReviewRole::try_from(r.role).unwrap(),
})
- .map_err(|_| NotFound("No such review"))
- .await?;
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ for override_user in override_users {
+ if let Some(user) = users
+ .iter_mut()
+ .find(|ue| ue.user.id == override_user.user.id)
+ {
+ user.role = override_user.role;
+ } else {
+ users.push(override_user);
+ }
+ }
+
+ review.users = users;
Ok(Json(review))
}
@@ -312,7 +600,21 @@ async fn main() -> Result<(), rocket::Error> {
let _rocket = rocket::build()
.attach(Db::init())
.attach(AdHoc::try_on_ignite("Database Migrations", run_migrations))
- .mount(basepath, routes![projects, project, reviews, review])
+ .mount(
+ basepath,
+ // Remember to update openapi paths when you add something here.
+ routes![
+ projects,
+ project,
+ project_new,
+ project_update,
+ project_user_add,
+ project_user_update,
+ project_user_del,
+ reviews,
+ review
+ ],
+ )
.mount(
"/",
SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api),