diff --git a/src/api/mod.rs b/src/api/mod.rs index c26b791..4243e4d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,11 +6,13 @@ use axum::{ use crate::{ database::DatabaseError, + persons::PersonError, tags::TagError, users::{UserError, auth::AuthError, sessions::SessionError}, }; mod auth; +mod persons; mod sessions; mod tags; mod users; @@ -39,6 +41,14 @@ pub fn api_router() -> Router { .route("/api/tags/{id}", patch(tags::rename)) .route("/api/tags/{id}", delete(tags::delete)) .route("/api/tags/#{name}", get(tags::get_by_name)) + // + .route("/api/persons", get(persons::get_all)) + .route("/api/persons", post(persons::create)) + .route("/api/persons/{id}", get(persons::get_by_id)) + .route("/api/persons/{id}/names", get(persons::pid_names)) + .route("/api/persons/{id}/addname", post(persons::add_name)) + .route("/api/names/{id}", get(persons::n_by_id)) + .route("/api/names/{id}/setprimary", post(persons::n_setprimary)) } pub struct CompositeError(Response); @@ -59,4 +69,11 @@ macro_rules! composite_from { )+ }; } -composite_from!(AuthError, UserError, SessionError, TagError, DatabaseError); +composite_from!( + AuthError, + UserError, + SessionError, + TagError, + PersonError, + DatabaseError +); diff --git a/src/api/persons.rs b/src/api/persons.rs new file mode 100644 index 0000000..55894ca --- /dev/null +++ b/src/api/persons.rs @@ -0,0 +1,83 @@ +use axum::{ + Json, + extract::Path, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + api::CompositeError, + persons::{Name, Person}, + users::{ + User, + auth::{UserAuthRequired, UserAuthenticate}, + permissions::Permission, + }, +}; + +pub const CANT_SET_PRIMARYNAME: &str = "You don't have permission to swap primary names."; + +pub async fn get_all(headers: HeaderMap) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Person::get_all()?).into_response()) +} +pub async fn get_by_id( + Path(id): Path, + headers: HeaderMap, +) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Person::get_by_id(id)?).into_response()) +} +pub async fn pid_names( + Path(id): Path, + headers: HeaderMap, +) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Person::get_by_id(id)?.get_all_names()?).into_response()) +} + +#[derive(Deserialize)] +pub struct PersonNameForm { + name: String, +} + +pub async fn create( + headers: HeaderMap, + Json(form): Json, +) -> Result { + let u = User::authenticate(&headers)?.required()?; + let p = Person::create(form.name, u.id)?; + Ok((StatusCode::CREATED, Json(p)).into_response()) +} +pub async fn add_name( + Path(id): Path, + headers: HeaderMap, + Json(form): Json, +) -> Result { + let u = User::authenticate(&headers)?.required()?; + let p = Person::get_by_id(id)?; + let n = p.add_name(form.name, u.id)?; + + Ok((StatusCode::CREATED, Json(n)).into_response()) +} + +pub async fn n_by_id(Path(id): Path, headers: HeaderMap) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Name::get_by_id(id)?).into_response()) +} +pub async fn n_setprimary( + Path(id): Path, + headers: HeaderMap, +) -> Result { + let u = User::authenticate(&headers)?.required()?; + if !u.has_permission(Permission::ChangePersonPrimaryName)? { + return Ok((StatusCode::FORBIDDEN, CANT_SET_PRIMARYNAME).into_response()); + } + + let mut n = Name::get_by_id(id)?; + n.set_primary()?; + n.is_primary = true; + Ok(Json(n).into_response()) +} diff --git a/src/persons/mod.rs b/src/persons/mod.rs index 1912359..d9192ec 100644 --- a/src/persons/mod.rs +++ b/src/persons/mod.rs @@ -1,4 +1,182 @@ -pub mod names; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use rusqlite::OptionalExtension; +use serde::Serialize; +use uuid::Uuid; -#[allow(unused)] -pub struct Person; +use crate::database::{self, DatabaseError}; + +#[derive(Serialize)] +pub struct Person { + pub id: Uuid, + pub primary_name: String, + pub created_by: Uuid, +} + +#[derive(Serialize)] +pub struct Name { + pub id: Uuid, + pub is_primary: bool, + pub person_id: Uuid, + pub created_by: Uuid, + pub name: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum PersonError { + #[error("No person found with ID {0}")] + NoPersonWithId(Uuid), + #[error("No name found with ID {0}")] + NoNameWithId(Uuid), + #[error("A name with this value already exists for this person")] + NameAlreadyExists, + #[error("This name is already the primary name")] + AlreadyPrimary, + #[error("{0}")] + DatabaseError(#[from] DatabaseError), +} + +impl Person { + pub fn get_all() -> Result, PersonError> { + Ok(database::conn()? + .prepare("SELECT p.id, p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1")? + .query_map((), |r| { + Ok(Person { + id: r.get(0)?, + created_by: r.get(1)?, + primary_name: r.get(2)?, + }) + })? + .collect::, _>>()?) + } + + pub fn get_by_id(id: Uuid) -> Result { + let res = database::conn()? + .prepare("SELECT p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1 WHERE p.id = ?1")? + .query_one((&id,), |r| { + Ok(Person { + id, + created_by: r.get(0)?, + primary_name: r.get(1)?, + }) + }) + .optional()?; + match res { + Some(p) => Ok(p), + None => Err(PersonError::NoPersonWithId(id)), + } + } + + pub fn get_all_names(&self) -> Result, PersonError> { + Ok(database::conn()? + .prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE person_id = ?1")? + .query_map((&self.id,), |r| { + Ok(Name { + id: r.get(0)?, + is_primary: r.get(1)?, + person_id: r.get(2)?, + created_by: r.get(3)?, + name: r.get(4)?, + }) + })? + .collect::, _>>()?) + } + + pub fn add_name(&self, name: String, created_by: Uuid) -> Result { + let id = Uuid::now_v7(); + database::conn()? + .prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? + .execute((id, 0, self.id, created_by, &name))?; + Ok(Name { + id, + is_primary: false, + person_id: self.id, + created_by, + name, + }) + } + + pub fn create(primary_name: String, created_by: Uuid) -> Result { + let person_id = Uuid::now_v7(); + let name_id = Uuid::now_v7(); + + let conn = database::conn()?; + conn.execute("BEGIN TRANSACTION", ())?; + + conn.prepare("INSERT INTO persons(id, created_by) VALUES (?1, ?2)")? + .execute((person_id, created_by))?; + conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? + .execute((name_id, 1, person_id, created_by, &primary_name))?; + conn.execute("COMMIT", ())?; + + Ok(Person { + id: person_id, + primary_name, + created_by, + }) + } +} + +impl Name { + pub fn get_by_id(id: Uuid) -> Result { + let res = database::conn()? + .prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE id = ?1")? + .query_one((&id,), |r| { + Ok(Name { + id: r.get(0)?, + is_primary: r.get(1)?, + person_id: r.get(2)?, + created_by: r.get(3)?, + name: r.get(4)?, + }) + }) + .optional()?; + match res { + Some(n) => Ok(n), + None => Err(PersonError::NoNameWithId(id)), + } + } + pub fn set_primary(&mut self) -> Result<(), PersonError> { + if self.is_primary { + return Err(PersonError::AlreadyPrimary); + } + + let conn = database::conn()?; + conn.execute("BEGIN TRANSACTION", ())?; + + conn.prepare("UPDATE names SET is_primary = 0 WHERE person_id = ?1 AND is_primary = 1")? + .execute((&self.person_id,))?; + conn.prepare("UPDATE names SET is_primary = 1 WHERE id = ?1")? + .execute((&self.id,))?; + + conn.execute("COMMIT", ())?; + self.is_primary = true; + Ok(()) + } +} + +impl From for PersonError { + fn from(error: rusqlite::Error) -> Self { + if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error + && e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + && msg.contains("name") + { + return PersonError::NameAlreadyExists; + } + PersonError::DatabaseError(DatabaseError::from(error)) + } +} + +impl IntoResponse for PersonError { + fn into_response(self) -> Response { + match self { + Self::DatabaseError(e) => e.into_response(), + Self::NoPersonWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + Self::NoNameWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + Self::NameAlreadyExists => (StatusCode::CONFLICT, self.to_string()).into_response(), + Self::AlreadyPrimary => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + } + } +} diff --git a/src/persons/names.rs b/src/persons/names.rs deleted file mode 100644 index 744e7c8..0000000 --- a/src/persons/names.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[allow(unused)] -pub struct Name; diff --git a/src/quotes/lines.rs b/src/quotes/lines.rs index dd37193..b062320 100644 --- a/src/quotes/lines.rs +++ b/src/quotes/lines.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use crate::persons::{Person, names::Name}; +use crate::persons::{Name, Person}; #[allow(unused)] pub struct QuoteLine { diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 2313d58..f8d9696 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -14,6 +14,7 @@ pub enum Permission { CreateTags, RenameTags, DeleteTags, + ChangePersonPrimaryName, } impl User {