Persons & Names full implementation

This commit is contained in:
2026-03-07 12:13:34 +01:00
parent f8f47cc3ff
commit ca84b7e4b4
6 changed files with 284 additions and 7 deletions

View File

@@ -6,11 +6,13 @@ use axum::{
use crate::{ use crate::{
database::DatabaseError, database::DatabaseError,
persons::PersonError,
tags::TagError, tags::TagError,
users::{UserError, auth::AuthError, sessions::SessionError}, users::{UserError, auth::AuthError, sessions::SessionError},
}; };
mod auth; mod auth;
mod persons;
mod sessions; mod sessions;
mod tags; mod tags;
mod users; mod users;
@@ -39,6 +41,14 @@ pub fn api_router() -> Router {
.route("/api/tags/{id}", patch(tags::rename)) .route("/api/tags/{id}", patch(tags::rename))
.route("/api/tags/{id}", delete(tags::delete)) .route("/api/tags/{id}", delete(tags::delete))
.route("/api/tags/#{name}", get(tags::get_by_name)) .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); 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
);

83
src/api/persons.rs Normal file
View File

@@ -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<Response, CompositeError> {
User::authenticate(&headers)?.required()?;
Ok(Json(Person::get_all()?).into_response())
}
pub async fn get_by_id(
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?;
Ok(Json(Person::get_by_id(id)?).into_response())
}
pub async fn pid_names(
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
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<PersonNameForm>,
) -> Result<Response, CompositeError> {
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<Uuid>,
headers: HeaderMap,
Json(form): Json<PersonNameForm>,
) -> Result<Response, CompositeError> {
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<Uuid>, headers: HeaderMap) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?;
Ok(Json(Name::get_by_id(id)?).into_response())
}
pub async fn n_setprimary(
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
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())
}

View File

@@ -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)] use crate::database::{self, DatabaseError};
pub struct Person;
#[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<Vec<Person>, 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::<Result<Vec<Person>, _>>()?)
}
pub fn get_by_id(id: Uuid) -> Result<Person, PersonError> {
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<Vec<Name>, 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::<Result<Vec<Name>, _>>()?)
}
pub fn add_name(&self, name: String, created_by: Uuid) -> Result<Name, PersonError> {
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<Person, PersonError> {
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<Name, PersonError> {
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<rusqlite::Error> 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(),
}
}
}

View File

@@ -1,2 +0,0 @@
#[allow(unused)]
pub struct Name;

View File

@@ -1,6 +1,6 @@
use uuid::Uuid; use uuid::Uuid;
use crate::persons::{Person, names::Name}; use crate::persons::{Name, Person};
#[allow(unused)] #[allow(unused)]
pub struct QuoteLine { pub struct QuoteLine {

View File

@@ -14,6 +14,7 @@ pub enum Permission {
CreateTags, CreateTags,
RenameTags, RenameTags,
DeleteTags, DeleteTags,
ChangePersonPrimaryName,
} }
impl User { impl User {