summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--server/migrations/eyeballs.sql2
-rw-r--r--server/src/api_model.rs48
-rw-r--r--server/src/auth.rs33
-rw-r--r--server/src/db_utils.rs138
-rw-r--r--server/src/main.rs448
5 files changed, 587 insertions, 82 deletions
diff --git a/server/migrations/eyeballs.sql b/server/migrations/eyeballs.sql
index 9699e6c..aeb1470 100644
--- a/server/migrations/eyeballs.sql
+++ b/server/migrations/eyeballs.sql
@@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS project_users (
project BIGINT UNSIGNED NOT NULL,
user BIGINT UNSIGNED NOT NULL,
+ default_role TINYINT UNSIGNED NOT NULL,
+ maintainer BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (project, user),
CONSTRAINT `fk_project_users_project`
diff --git a/server/src/api_model.rs b/server/src/api_model.rs
index a7c8e88..f3bb5cf 100644
--- a/server/src/api_model.rs
+++ b/server/src/api_model.rs
@@ -1,7 +1,7 @@
-use rocket::serde::Serialize;
+use rocket::serde::{Deserialize, Serialize};
use utoipa::ToSchema;
-#[derive(Serialize, Copy, Clone, ToSchema)]
+#[derive(Deserialize, Serialize, Copy, Clone, ToSchema)]
pub enum ReviewState {
Draft,
Open,
@@ -9,6 +9,13 @@ pub enum ReviewState {
Closed,
}
+#[derive(Deserialize, Serialize, Copy, Clone, ToSchema)]
+pub enum UserReviewRole {
+ Reviewer,
+ Watcher,
+ None,
+}
+
#[derive(Serialize, ToSchema)]
pub struct User {
#[schema(example = 1337u64)]
@@ -22,6 +29,13 @@ pub struct User {
}
#[derive(Serialize, ToSchema)]
+pub struct ReviewUserEntry {
+ pub user: User,
+ #[schema(example = UserReviewRole::Reviewer)]
+ pub role: UserReviewRole,
+}
+
+#[derive(Serialize, ToSchema)]
pub struct Review {
#[schema(example = 1000u64)]
pub id: u64,
@@ -30,8 +44,7 @@ pub struct Review {
#[schema(example = "We're adding more features because features are what we want.")]
pub description: String,
pub owner: User,
- pub reviewers: Vec<User>,
- pub watchers: Vec<User>,
+ pub users: Vec<ReviewUserEntry>,
#[schema(example = ReviewState::Open)]
pub state: ReviewState,
#[schema(example = 37.5)]
@@ -65,6 +78,23 @@ pub struct Reviews {
}
#[derive(Serialize, ToSchema)]
+pub struct ProjectUserEntry {
+ pub user: User,
+ #[schema(example = UserReviewRole::Reviewer)]
+ pub default_role: UserReviewRole,
+ #[schema(example = false)]
+ pub maintainer: bool,
+}
+
+#[derive(Deserialize, ToSchema)]
+pub struct ProjectUserEntryData {
+ #[schema(example = UserReviewRole::Reviewer)]
+ pub default_role: Option<UserReviewRole>,
+ #[schema(example = false)]
+ pub maintainer: Option<bool>,
+}
+
+#[derive(Serialize, ToSchema)]
pub struct Project {
#[schema(example = 1u64)]
pub id: u64,
@@ -72,7 +102,15 @@ pub struct Project {
pub title: String,
#[schema(example = "Example project")]
pub description: String,
- pub members: Vec<User>,
+ pub users: Vec<ProjectUserEntry>,
+}
+
+#[derive(Deserialize, ToSchema)]
+pub struct ProjectData<'r> {
+ #[schema(example = "FAKE: Features All Kids Erase")]
+ pub title: Option<&'r str>,
+ #[schema(example = "Example project")]
+ pub description: Option<&'r str>,
}
#[derive(Serialize, ToSchema)]
diff --git a/server/src/auth.rs b/server/src/auth.rs
index c827126..f1b8f70 100644
--- a/server/src/auth.rs
+++ b/server/src/auth.rs
@@ -1,5 +1,6 @@
use core::net::IpAddr;
-use rocket::fairing::AdHoc;
+use futures::future::TryFutureExt;
+use rocket::fairing::{self, AdHoc};
use rocket::form::Form;
use rocket::http::{Cookie, CookieJar, Status};
use rocket::outcome::{try_outcome, IntoOutcome};
@@ -7,7 +8,8 @@ use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::status::Unauthorized;
use rocket::serde::json::{self, Json};
use rocket::serde::{Deserialize, Serialize};
-use rocket::State;
+use rocket::{Build, Rocket, State};
+use rocket_db_pools::{sqlx, Connection, Database};
use std::collections::BTreeMap;
use std::sync::Mutex;
use std::time::Instant;
@@ -16,6 +18,7 @@ use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::{Modify, OpenApi, ToSchema};
use crate::api_model;
+use crate::Db;
#[derive(OpenApi)]
#[openapi(
@@ -164,16 +167,24 @@ fn new_session(
),
)]
#[post("/login", data = "<login>")]
-fn login(
+async fn login(
auth_config: &State<AuthConfig>,
sessions: &State<Sessions>,
ipaddr: IpAddr,
cookies: &CookieJar<'_>,
+ mut db: Connection<Db>,
login: Form<Login<'_>>,
) -> Result<Json<api_model::StatusResponse>, Unauthorized<&'static str>> {
if login.username == "user" && login.password == "password" {
+ let user_id = sqlx::query!("SELECT id FROM users WHERE username=?", login.username)
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.id)
+ .map_err(|_| Unauthorized("Unknown username or password"))
+ .await
+ .unwrap();
+
let max_age = Duration::days(i64::from(auth_config.session_max_age_days));
- let session = new_session(sessions, 1u64, ipaddr.to_string(), max_age);
+ let session = new_session(sessions, user_id, ipaddr.to_string(), max_age);
let cookie = Cookie::build((SESSION_COOKIE, json::to_string(&session).unwrap()))
.path("/api")
@@ -237,6 +248,19 @@ fn unauthorized() -> Json<api_model::StatusResponse> {
Json(STATUS_UNAUTHORIZED)
}
+async fn run_import(rocket: Rocket<Build>) -> fairing::Result {
+ match Db::fetch(&rocket) {
+ Some(db) => match sqlx::query!("INSERT IGNORE INTO users (username) VALUES (?)", "user")
+ .execute(&**db)
+ .await
+ {
+ Ok(_) => Ok(rocket),
+ Err(_) => Err(rocket),
+ },
+ None => Err(rocket),
+ }
+}
+
pub fn stage(basepath: &str) -> AdHoc {
let l_basepath = basepath.to_string();
AdHoc::on_ignite("Auth Stage", |rocket| async {
@@ -248,6 +272,7 @@ pub fn stage(basepath: &str) -> AdHoc {
}),
})
.attach(AdHoc::config::<AuthConfig>())
+ .attach(AdHoc::try_on_ignite("Auth Import", run_import))
.mount(l_basepath.clone(), routes![login, logout, status])
.register(l_basepath, catchers![unauthorized])
})
diff --git a/server/src/db_utils.rs b/server/src/db_utils.rs
new file mode 100644
index 0000000..e74fd68
--- /dev/null
+++ b/server/src/db_utils.rs
@@ -0,0 +1,138 @@
+#![allow(dead_code)]
+
+use sqlx::database::HasArguments;
+use sqlx::encode::Encode;
+use sqlx::types::Type;
+use sqlx::{Arguments, Database};
+use std::fmt::Display;
+
+pub struct UpdateBuilder<'args, DB>
+where
+ DB: Database,
+{
+ table: Option<String>,
+ names: Option<Vec<(String, String)>>,
+ where_: Option<Vec<(String, String, String)>>,
+ values: Option<<DB as HasArguments<'args>>::Arguments>,
+}
+
+impl<'args, DB: Database> UpdateBuilder<'args, DB>
+where
+ DB: Database,
+{
+ pub fn new() -> Self
+ where
+ <DB as HasArguments<'args>>::Arguments: Default,
+ {
+ UpdateBuilder {
+ table: None,
+ names: Some(Vec::new()),
+ where_: Some(Vec::new()),
+ values: Some(Default::default()),
+ }
+ }
+
+ #[inline]
+ fn sanity_check(&self) {
+ assert!(
+ self.values.is_some(),
+ "UpdateBuilder must be reset before reuse after `.build()`"
+ );
+ }
+
+ pub fn table(&mut self, table: impl Display) -> &mut Self {
+ self.sanity_check();
+
+ self.table = Some(format!("{table}"));
+
+ self
+ }
+
+ pub fn and_where<T>(&mut self, name: impl Display, op: impl Display, value: T) -> &mut Self
+ where
+ T: 'args + Encode<'args, DB> + Send + Type<DB>,
+ {
+ self.sanity_check();
+
+ let values = self.values.as_mut().expect("BUG: Values taken already");
+ values.add(value);
+
+ let mut placeholder = String::new();
+ values
+ .format_placeholder(&mut placeholder)
+ .expect("error in format_placeholder");
+
+ let where_ = self.where_.as_mut().expect("BUG: Where taken already");
+ where_.push((format!("{name}"), format!("{op}"), placeholder));
+
+ self
+ }
+
+ pub fn set<T>(&mut self, name: impl Display, value: T) -> &mut Self
+ where
+ T: 'args + Encode<'args, DB> + Send + Type<DB>,
+ {
+ self.sanity_check();
+
+ assert!(
+ self.where_.as_ref().unwrap().is_empty(),
+ "set must not be called after add_where"
+ );
+
+ let names = self.names.as_mut().expect("BUG: Names taken already");
+ let values = self.values.as_mut().expect("BUG: Values taken already");
+ values.add(value);
+
+ let mut placeholder = String::new();
+ values
+ .format_placeholder(&mut placeholder)
+ .expect("error in format_placeholder");
+ names.push((format!("{name}"), placeholder));
+
+ self
+ }
+
+ pub fn build(&mut self) -> (String, <DB as HasArguments<'args>>::Arguments) {
+ self.sanity_check();
+
+ let table = self.table.take().unwrap();
+ let mut query = format!("UPDATE {table} SET");
+ let mut first = true;
+ for (name, placeholder) in self.names.take().unwrap() {
+ if first {
+ first = false;
+ } else {
+ query.push(',');
+ }
+ query.push_str(&format!(" {name}={placeholder}"));
+ }
+
+ let where_ = self.where_.take().unwrap();
+ if !where_.is_empty() {
+ query.push_str(" WHERE");
+
+ first = true;
+ for (name, op, placeholder) in where_ {
+ if first {
+ first = false;
+ } else {
+ query.push_str(" AND");
+ }
+ query.push_str(&format!(" {name}{op}{placeholder}"));
+ }
+ }
+
+ // TODO: This method should return a Query, constructed by QueryBuilder, but I can't
+ // figure out how to create QueryBuilder::with_arguments in this generic method,
+ // where DB isn't "known".
+ (query, self.values.take().unwrap())
+ }
+
+ pub fn reset(&mut self) -> &mut Self {
+ self.names = Some(Vec::new());
+ self.where_ = Some(Vec::new());
+ self.values = Some(Default::default());
+
+ self
+ }
+}
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),