From efcac1edc0b06a29d83845d01c65ce10a79513ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:34:00 +0200 Subject: [PATCH] refactor and implement user --- ...c2a78c8d2f362142ce15ca28ee93c47bfbb7f.json | 12 + rust/src/bin/adm.rs | 101 +- rust/src/error.rs | 15 + rust/src/lib.rs | 178 ++- rust/src/main.rs | 1340 +---------------- rust/src/models/user.rs | 24 + rust/src/routing/mod.rs | 153 ++ rust/src/routing/routes.rs | 1026 +++++++++++++ rust/src/view/mod.rs | 4 +- 9 files changed, 1509 insertions(+), 1344 deletions(-) create mode 100644 rust/.sqlx/query-961ee325bfb6af3005bad00f0c5c2a78c8d2f362142ce15ca28ee93c47bfbb7f.json create mode 100644 rust/src/routing/mod.rs create mode 100644 rust/src/routing/routes.rs diff --git a/rust/.sqlx/query-961ee325bfb6af3005bad00f0c5c2a78c8d2f362142ce15ca28ee93c47bfbb7f.json b/rust/.sqlx/query-961ee325bfb6af3005bad00f0c5c2a78c8d2f362142ce15ca28ee93c47bfbb7f.json new file mode 100644 index 0000000..214dd85 --- /dev/null +++ b/rust/.sqlx/query-961ee325bfb6af3005bad00f0c5c2a78c8d2f362142ce15ca28ee93c47bfbb7f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users\n (id, username, fullname)\n VALUES\n (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "961ee325bfb6af3005bad00f0c5c2a78c8d2f362142ce15ca28ee93c47bfbb7f" +} diff --git a/rust/src/bin/adm.rs b/rust/src/bin/adm.rs index 3de6465..b0e0e7d 100644 --- a/rust/src/bin/adm.rs +++ b/rust/src/bin/adm.rs @@ -1,8 +1,107 @@ -use clap::Parser; +use std::fmt; +use std::process::exit; + +use clap::{Parser, Subcommand}; + +use packager::{models, sqlite, StartError}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { #[arg(long)] database_url: String, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + #[command(subcommand)] + User(UserCommand), +} + +#[derive(Subcommand, Debug)] +enum UserCommand { + Create(UserCreate), +} + +#[derive(Parser, Debug)] +struct UserCreate { + #[arg(long)] + username: String, + #[arg(long)] + fullname: String, +} + +#[derive(Debug)] +enum Error { + Generic { message: String }, + UserExists { username: String }, +} + +impl std::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Generic { message } => write!(f, "{}", message), + Self::UserExists { username } => write!(f, "user \"{username}\" already exists"), + } + } +} + +impl From for Error { + fn from(starterror: StartError) -> Self { + Self::Generic { + message: starterror.to_string(), + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + + let database_pool = sqlite::init_database_pool(&args.database_url).await?; + + match args.command { + Command::User(cmd) => match cmd { + UserCommand::Create(user) => { + let id = match models::user::create( + &database_pool, + models::user::NewUser { + username: &user.username, + fullname: &user.fullname, + }, + ) + .await + { + Ok(id) => id, + Err(error) => { + if let models::Error::Query(models::QueryError::Duplicate { + description: _, + }) = error + { + println!( + "Error: {}", + Error::UserExists { + username: user.username, + } + .to_string() + ); + exit(1); + } + return Err(error.into()); + } + }; + println!( + "User \"{}\" created successfully (id {})", + user.username, id + ) + } + }, + } + + Ok(()) } diff --git a/rust/src/error.rs b/rust/src/error.rs index f43d13b..6687c94 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -47,6 +47,21 @@ pub enum StartError { DatabaseMigrationError { message: String }, } +impl fmt::Display for StartError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::DatabaseInitError { message } => { + write!(f, "database initialization error: {message}") + } + Self::DatabaseMigrationError { message } => { + write!(f, "database migration error: {message}") + } + } + } +} + +impl std::error::Error for StartError {} + impl From for StartError { fn from(value: sqlx::Error) -> Self { Self::DatabaseInitError { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3139801..520d8a5 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,5 +1,175 @@ -mod error; -mod models; -mod sqlite; +use axum::{extract::State, http::header::HeaderValue, middleware::Next, response::IntoResponse}; -use error::{Error, RequestError, StartError}; +use hyper::Request; + +use uuid::Uuid; + +use std::fmt; + +pub mod error; +pub mod models; +pub mod routing; +pub mod sqlite; + +mod html; +mod view; + +pub use error::{Error, RequestError, StartError}; + +#[derive(Clone)] +pub enum AuthConfig { + Enabled, + Disabled { assume_user: String }, +} + +#[derive(Clone)] +pub struct AppState { + pub database_pool: sqlite::Pool, + pub client_state: ClientState, + pub auth_config: AuthConfig, +} + +#[derive(Clone)] +pub struct Context { + user: models::user::User, +} + +impl Context { + fn build(user: models::user::User) -> Self { + Self { user } + } +} + +#[derive(Clone)] +pub struct ClientState { + pub active_category_id: Option, + pub edit_item: Option, + pub trip_edit_attribute: Option, + pub trip_type_edit: Option, +} + +impl ClientState { + pub fn new() -> Self { + ClientState { + active_category_id: None, + edit_item: None, + trip_edit_attribute: None, + trip_type_edit: None, + } + } +} + +impl Default for ClientState { + fn default() -> Self { + Self::new() + } +} + +struct UriPath(String); + +impl fmt::Display for UriPath { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'a> Into<&'a str> for &'a UriPath { + fn into(self) -> &'a str { + self.0.as_str() + } +} + +#[derive(PartialEq, Eq)] +pub enum TopLevelPage { + Inventory, + Trips, +} + +impl TopLevelPage { + fn id(&self) -> &'static str { + match self { + Self::Inventory => "inventory", + Self::Trips => "trips", + } + } + + fn path(&self) -> UriPath { + UriPath( + match self { + Self::Inventory => "/inventory/", + Self::Trips => "/trips/", + } + .to_string(), + ) + } + + fn name(&self) -> &'static str { + match self { + Self::Inventory => "Inventory", + Self::Trips => "Trips", + } + } +} + +enum HtmxEvents { + TripItemEdited, +} + +impl From for HeaderValue { + fn from(val: HtmxEvents) -> Self { + HeaderValue::from_static(val.to_str()) + } +} + +impl HtmxEvents { + fn to_str(&self) -> &'static str { + match self { + Self::TripItemEdited => "TripItemEdited", + } + } +} + +async fn authorize( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let current_user = match state.auth_config { + AuthConfig::Disabled { assume_user } => { + match models::user::User::find_by_name(&state.database_pool, &assume_user).await? { + Some(user) => user, + None => { + return Err(Error::Request(RequestError::AuthenticationUserNotFound { + username: assume_user, + })) + } + } + } + AuthConfig::Enabled => { + let Some(username) = request.headers().get("x-auth-username") else { + return Err(Error::Request(RequestError::AuthenticationHeaderMissing)); + }; + + let username = username + .to_str() + .map_err(|error| { + Error::Request(RequestError::AuthenticationHeaderInvalid { + message: error.to_string(), + }) + })? + .to_string(); + + match models::user::User::find_by_name(&state.database_pool, &username).await? { + Some(user) => user, + None => { + return Err(Error::Request(RequestError::AuthenticationUserNotFound { + username, + })) + } + } + } + }; + + request.extensions_mut().insert(current_user); + Ok(next.run(request).await) +} diff --git a/rust/src/main.rs b/rust/src/main.rs index f2c46d3..ecf80e1 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,53 +1,8 @@ -use axum::{ - extract::{Extension, Path, Query, State}, - http::header::{self, HeaderMap, HeaderName, HeaderValue}, - middleware::{self, Next}, - response::{IntoResponse, Redirect}, - routing::{get, post}, - Form, Router, -}; +use packager::{routing, sqlite, AppState, AuthConfig, ClientState, StartError}; -use hyper::Request; - -use serde::Deserialize; - -use uuid::Uuid; - -use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; -mod html; -mod view; - -type User = models::user::User; - -use error::{Error, RequestError, StartError}; - -#[derive(Clone)] -pub enum AuthConfig { - Enabled, - Disabled { assume_user: String }, -} - -#[derive(Clone)] -pub struct AppState { - database_pool: sqlite::Pool, - client_state: ClientState, - auth_config: AuthConfig, -} - -#[derive(Clone)] -pub struct Context { - user: User, -} - -impl Context { - fn build(user: User) -> Self { - Self { user } - } -} - use clap::Parser; #[derive(Parser, Debug)] @@ -63,166 +18,6 @@ struct Args { disable_auth_and_assume_user: Option, } -#[derive(Clone)] -pub struct ClientState { - pub active_category_id: Option, - pub edit_item: Option, - pub trip_edit_attribute: Option, - pub trip_type_edit: Option, -} - -impl ClientState { - pub fn new() -> Self { - ClientState { - active_category_id: None, - edit_item: None, - trip_edit_attribute: None, - trip_type_edit: None, - } - } -} - -impl Default for ClientState { - fn default() -> Self { - Self::new() - } -} - -struct UriPath(String); - -impl fmt::Display for UriPath { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl<'a> Into<&'a str> for &'a UriPath { - fn into(self) -> &'a str { - self.0.as_str() - } -} - -#[derive(PartialEq, Eq)] -pub enum TopLevelPage { - Inventory, - Trips, -} - -impl TopLevelPage { - fn id(&self) -> &'static str { - match self { - Self::Inventory => "inventory", - Self::Trips => "trips", - } - } - - fn path(&self) -> UriPath { - UriPath( - match self { - Self::Inventory => "/inventory/", - Self::Trips => "/trips/", - } - .to_string(), - ) - } - - fn name(&self) -> &'static str { - match self { - Self::Inventory => "Inventory", - Self::Trips => "Trips", - } - } -} - -enum HtmxEvents { - TripItemEdited, -} - -impl From for HeaderValue { - fn from(val: HtmxEvents) -> Self { - HeaderValue::from_static(val.to_str()) - } -} - -impl HtmxEvents { - fn to_str(&self) -> &'static str { - match self { - Self::TripItemEdited => "TripItemEdited", - } - } -} - -enum HtmxResponseHeaders { - Trigger, - PushUrl, -} - -impl From for HeaderName { - fn from(val: HtmxResponseHeaders) -> Self { - match val { - HtmxResponseHeaders::Trigger => HeaderName::from_static("hx-trigger"), - HtmxResponseHeaders::PushUrl => HeaderName::from_static("hx-push-url"), - } - } -} - -enum HtmxRequestHeaders { - HtmxRequest, -} - -impl From for HeaderName { - fn from(val: HtmxRequestHeaders) -> Self { - match val { - HtmxRequestHeaders::HtmxRequest => HeaderName::from_static("hx-request"), - } - } -} - -async fn authorize( - State(state): State, - mut request: Request, - next: Next, -) -> Result { - let current_user = match state.auth_config { - AuthConfig::Disabled { assume_user } => { - match models::user::User::find_by_name(&state.database_pool, &assume_user).await? { - Some(user) => user, - None => { - return Err(Error::Request(RequestError::AuthenticationUserNotFound { - username: assume_user, - })) - } - } - } - AuthConfig::Enabled => { - let Some(username) = request.headers().get("x-auth-username") else { - return Err(Error::Request(RequestError::AuthenticationHeaderMissing)); - }; - - let username = username - .to_str() - .map_err(|error| { - Error::Request(RequestError::AuthenticationHeaderInvalid { - message: error.to_string(), - }) - })? - .to_string(); - - match models::user::User::find_by_name(&state.database_pool, &username).await? { - Some(user) => user, - None => { - return Err(Error::Request(RequestError::AuthenticationUserNotFound { - username, - })) - } - } - } - }; - - request.extensions_mut().insert(current_user); - Ok(next.run(request).await) -} - #[tokio::main] async fn main() -> Result<(), StartError> { tracing_subscriber::fmt() @@ -231,7 +26,7 @@ async fn main() -> Result<(), StartError> { let args = Args::parse(); - let database_pool = sqlie::init_database_pool(&args.database_url).await?; + let database_pool = sqlite::init_database_pool(&args.database_url).await?; sqlite::migrate(&database_pool).await?; let state = AppState { @@ -244,109 +39,8 @@ async fn main() -> Result<(), StartError> { }, }; - let icon_handler = || async { - ( - [(header::CONTENT_TYPE, "image/svg+xml")], - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/luggage.svg")), - ) - }; - // build our application with a route - let app = Router::new() - .route("/favicon.svg", get(icon_handler)) - .route("/assets/luggage.svg", get(icon_handler)) - .route( - "/notfound", - get(|| async { - Error::Request(RequestError::NotFound { - message: "hi".to_string(), - }) - }), - ) - .route("/debug", get(debug)) - .merge( - // thse are routes that require authentication - Router::new() - .route("/", get(root)) - .nest( - (&TopLevelPage::Trips.path()).into(), - Router::new() - .route("/", get(trips).post(trip_create)) - .route("/types/", get(trips_types).post(trip_type_create)) - .route("/types/:id/edit/name/submit", post(trips_types_edit_name)) - .route("/:id/", get(trip)) - .route("/:id/comment/submit", post(trip_comment_set)) - .route("/:id/categories/:id/select", post(trip_category_select)) - .route("/:id/packagelist/", get(trip_packagelist)) - .route( - "/:id/packagelist/item/:id/pack", - post(trip_item_packagelist_set_pack_htmx), - ) - .route( - "/:id/packagelist/item/:id/unpack", - post(trip_item_packagelist_set_unpack_htmx), - ) - .route( - "/:id/packagelist/item/:id/ready", - post(trip_item_packagelist_set_ready_htmx), - ) - .route( - "/:id/packagelist/item/:id/unready", - post(trip_item_packagelist_set_unready_htmx), - ) - .route("/:id/state/:id", post(trip_state_set)) - .route("/:id/total_weight", get(trip_total_weight_htmx)) - .route("/:id/type/:id/add", get(trip_type_add)) - .route("/:id/type/:id/remove", get(trip_type_remove)) - .route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) - .route( - "/:id/items/:id/pick", - get(trip_item_set_pick).post(trip_item_set_pick_htmx), - ) - .route( - "/:id/items/:id/unpick", - get(trip_item_set_unpick).post(trip_item_set_unpick_htmx), - ) - .route( - "/:id/items/:id/pack", - get(trip_item_set_pack).post(trip_item_set_pack_htmx), - ) - .route( - "/:id/items/:id/unpack", - get(trip_item_set_unpack).post(trip_item_set_unpack_htmx), - ) - .route( - "/:id/items/:id/ready", - get(trip_item_set_ready).post(trip_item_set_ready_htmx), - ) - .route( - "/:id/items/:id/unready", - get(trip_item_set_unready).post(trip_item_set_unready_htmx), - ), - ) - .nest( - (&TopLevelPage::Inventory.path()).into(), - Router::new() - .route("/", get(inventory_inactive)) - .route("/categories/:id/select", post(inventory_category_select)) - .route("/category/", post(inventory_category_create)) - .route("/category/:id/", get(inventory_active)) - .route("/item/", post(inventory_item_create)) - .route("/item/:id/", get(inventory_item)) - .route("/item/:id/cancel", get(inventory_item_cancel)) - .route("/item/:id/delete", get(inventory_item_delete)) - .route("/item/:id/edit", post(inventory_item_edit)) - .route("/item/name/validate", post(inventory_item_validate_name)), - ) - .layer(middleware::from_fn_with_state(state.clone(), authorize)), - ) - .fallback(|| async { - Error::Request(RequestError::NotFound { - message: "no route found".to_string(), - }) - }) - .with_state(state); - + let app = routing::router(state); let addr = SocketAddr::from(( IpAddr::from_str(&args.bind) .map_err(|error| format!("error parsing bind address {}: {}", &args.bind, error)) @@ -363,1031 +57,3 @@ async fn main() -> Result<(), StartError> { Ok(()) } - -async fn root(Extension(current_user): Extension) -> impl IntoResponse { - view::Root::build( - &Context::build(current_user), - &view::home::Home::build(), - None, - ) -} - -async fn debug(headers: HeaderMap) -> impl IntoResponse { - let out = { - let mut out = String::new(); - for (key, value) in headers.iter() { - out.push_str(&format!("{}: {}\n", key, value.to_str().unwrap())); - } - out - }; - out -} - -#[derive(Deserialize, Default)] -struct InventoryQuery { - edit_item: Option, -} - -async fn inventory_active( - Extension(current_user): Extension, - State(mut state): State, - Path(id): Path, - Query(inventory_query): Query, -) -> Result { - state.client_state.edit_item = inventory_query.edit_item; - state.client_state.active_category_id = Some(id); - - let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - - let active_category: Option<&models::inventory::Category> = state - .client_state - .active_category_id - .map(|id| { - inventory - .categories - .iter() - .find(|category| category.id == id) - .ok_or(Error::Request(RequestError::NotFound { - message: format!("a category with id {id} does not exist"), - })) - }) - .transpose()?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::inventory::Inventory::build( - active_category, - &inventory.categories, - state.client_state.edit_item, - ), - Some(&TopLevelPage::Inventory), - )) -} - -async fn inventory_inactive( - Extension(current_user): Extension, - State(mut state): State, - Query(inventory_query): Query, -) -> Result { - state.client_state.edit_item = inventory_query.edit_item; - state.client_state.active_category_id = None; - - let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::inventory::Inventory::build( - None, - &inventory.categories, - state.client_state.edit_item, - ), - Some(&TopLevelPage::Inventory), - )) -} - -#[derive(Deserialize)] -struct NewItem { - #[serde(rename = "new-item-name")] - name: String, - #[serde(rename = "new-item-weight")] - weight: u32, - // damn i just love how serde is integrated everywhere, just add a feature to the uuid in - // cargo.toml and go - #[serde(rename = "new-item-category-id")] - category_id: Uuid, -} - -#[derive(Deserialize)] -struct NewItemName { - #[serde(rename = "new-item-name")] - name: String, -} - -async fn inventory_item_validate_name( - State(state): State, - Form(new_item): Form, -) -> Result { - let exists = - models::inventory::InventoryItem::name_exists(&state.database_pool, &new_item.name).await?; - - Ok(view::inventory::InventoryNewItemFormName::build( - Some(&new_item.name), - exists, - )) -} - -async fn inventory_item_create( - State(state): State, - headers: HeaderMap, - Form(new_item): Form, -) -> Result { - if new_item.name.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let _new_id = models::inventory::InventoryItem::save( - &state.database_pool, - &new_item.name, - new_item.category_id, - new_item.weight, - ) - .await?; - - if is_htmx(&headers) { - let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - - // it's impossible to NOT find the item here, as we literally just added - // it. - let active_category: Option<&models::inventory::Category> = Some( - inventory - .categories - .iter() - .find(|category| category.id == new_item.category_id) - .unwrap(), - ); - - Ok(view::inventory::Inventory::build( - active_category, - &inventory.categories, - state.client_state.edit_item, - ) - .into_response()) - } else { - Ok(Redirect::to(&format!( - "/inventory/category/{id}/", - id = new_item.category_id - )) - .into_response()) - } -} - -fn get_referer<'a>(headers: &'a HeaderMap) -> Result<&'a str, Error> { - headers - .get("referer") - .ok_or(Error::Request(RequestError::RefererNotFound))? - .to_str() - .map_err(|error| { - Error::Request(RequestError::RefererInvalid { - message: error.to_string(), - }) - }) -} - -async fn inventory_item_delete( - State(state): State, - headers: HeaderMap, - Path(id): Path, -) -> Result { - let deleted = models::inventory::InventoryItem::delete(&state.database_pool, id).await?; - - if !deleted { - Err(Error::Request(RequestError::NotFound { - message: format!("item with id {id} not found"), - })) - } else { - Ok(Redirect::to(get_referer(&headers)?)) - } -} - -#[derive(Deserialize)] -struct EditItem { - #[serde(rename = "edit-item-name")] - name: String, - #[serde(rename = "edit-item-weight")] - weight: u32, -} - -async fn inventory_item_edit( - State(state): State, - Path(id): Path, - Form(edit_item): Form, -) -> Result { - if edit_item.name.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let id = models::inventory::InventoryItem::update( - &state.database_pool, - id, - &edit_item.name, - edit_item.weight, - ) - .await?; - - Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id))) -} - -async fn inventory_item_cancel( - State(state): State, - Path(id): Path, -) -> Result { - let id = models::inventory::InventoryItem::find(&state.database_pool, id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("item with id {id} not found"), - }))?; - - Ok(Redirect::to(&format!( - "/inventory/category/{id}/", - id = id.category.id - ))) -} - -#[derive(Deserialize)] -struct NewTrip { - #[serde(rename = "new-trip-name")] - name: String, - #[serde(rename = "new-trip-start-date")] - date_start: time::Date, - #[serde(rename = "new-trip-end-date")] - date_end: time::Date, -} - -async fn trip_create( - State(state): State, - Form(new_trip): Form, -) -> Result { - if new_trip.name.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let new_id = models::trips::Trip::save( - &state.database_pool, - &new_trip.name, - new_trip.date_start, - new_trip.date_end, - ) - .await?; - - Ok(Redirect::to(&format!("/trips/{new_id}/"))) -} - -async fn trips( - Extension(current_user): Extension, - State(state): State, -) -> Result { - let trips = models::trips::Trip::all(&state.database_pool).await?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::trip::TripManager::build(trips), - Some(&TopLevelPage::Trips), - )) -} - -#[derive(Debug, Deserialize)] -struct TripQuery { - edit: Option, - category: Option, -} - -async fn trip( - Extension(current_user): Extension, - State(mut state): State, - Path(id): Path, - Query(trip_query): Query, -) -> Result { - state.client_state.trip_edit_attribute = trip_query.edit; - state.client_state.active_category_id = trip_query.category; - - let mut trip: models::trips::Trip = models::trips::Trip::find(&state.database_pool, id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("trip with id {id} not found"), - }))?; - - trip.load_trips_types(&state.database_pool).await?; - - trip.sync_trip_items_with_inventory(&state.database_pool) - .await?; - - trip.load_categories(&state.database_pool).await?; - - let active_category: Option<&models::trips::TripCategory> = state - .client_state - .active_category_id - .map(|id| { - trip.categories() - .iter() - .find(|category| category.category.id == id) - .ok_or(Error::Request(RequestError::NotFound { - message: format!("an active category with id {id} does not exist"), - })) - }) - .transpose()?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::trip::Trip::build( - &trip, - state.client_state.trip_edit_attribute, - active_category, - ), - Some(&TopLevelPage::Trips), - )) -} - -async fn trip_type_remove( - State(state): State, - Path((trip_id, type_id)): Path<(Uuid, Uuid)>, -) -> Result { - let found = - models::trips::Trip::trip_type_remove(&state.database_pool, trip_id, type_id).await?; - - if !found { - Err(Error::Request(RequestError::NotFound { - message: format!("type {type_id} is not active for trip {trip_id}"), - })) - } else { - Ok(Redirect::to(&format!("/trips/{trip_id}/"))) - } -} - -async fn trip_type_add( - State(state): State, - Path((trip_id, type_id)): Path<(Uuid, Uuid)>, -) -> Result { - models::trips::Trip::trip_type_add(&state.database_pool, trip_id, type_id).await?; - - Ok(Redirect::to(&format!("/trips/{trip_id}/"))) -} - -#[derive(Deserialize)] -struct CommentUpdate { - #[serde(rename = "new-comment")] - new_comment: String, -} - -async fn trip_comment_set( - State(state): State, - Path(trip_id): Path, - Form(comment_update): Form, -) -> Result { - let found = models::trips::Trip::set_comment( - &state.database_pool, - trip_id, - &comment_update.new_comment, - ) - .await?; - - if !found { - Err(Error::Request(RequestError::NotFound { - message: format!("trip with id {trip_id} not found"), - })) - } else { - Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id))) - } -} - -#[derive(Deserialize)] -struct TripUpdate { - #[serde(rename = "new-value")] - new_value: String, -} - -async fn trip_edit_attribute( - State(state): State, - Path((trip_id, attribute)): Path<(Uuid, models::trips::TripAttribute)>, - Form(trip_update): Form, -) -> Result { - if attribute == models::trips::TripAttribute::Name { - if trip_update.new_value.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - } - models::trips::Trip::set_attribute( - &state.database_pool, - trip_id, - attribute, - &trip_update.new_value, - ) - .await?; - - Ok(Redirect::to(&format!("/trips/{trip_id}/"))) -} - -async fn trip_item_set_state( - state: &AppState, - trip_id: Uuid, - item_id: Uuid, - key: models::trips::TripItemStateKey, - value: bool, -) -> Result<(), Error> { - models::trips::TripItem::set_state(&state.database_pool, trip_id, item_id, key, value).await?; - Ok(()) -} - -async fn trip_row( - state: &AppState, - trip_id: Uuid, - item_id: Uuid, -) -> Result { - let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) - .await? - .ok_or_else(|| { - Error::Request(RequestError::NotFound { - message: format!("item with id {item_id} not found for trip {trip_id}"), - }) - })?; - - let item_row = view::trip::TripItemListRow::build( - trip_id, - &item, - models::inventory::InventoryItem::get_category_max_weight( - &state.database_pool, - item.item.category_id, - ) - .await?, - ); - - let category = - models::trips::TripCategory::find(&state.database_pool, trip_id, item.item.category_id) - .await? - .ok_or_else(|| { - Error::Request(RequestError::NotFound { - message: format!("category with id {} not found", item.item.category_id), - }) - })?; - - // TODO biggest_category_weight? - let category_row = view::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true); - - Ok(html::concat(item_row, category_row)) -} - -async fn trip_item_set_pick( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pick, - true, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_pick_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pick, - true, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_item_set_unpick( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pick, - false, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_unpick_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pick, - false, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_item_set_pack( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - true, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_pack_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - true, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_item_set_unpack( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - false, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_unpack_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - false, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_item_set_ready( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - true, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_ready_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - true, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_item_set_unready( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - Ok::<_, Error>( - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - false, - ) - .await?, - ) - .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? -} - -async fn trip_item_set_unready_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - false, - ) - .await?; - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::Trigger.into(), - HtmxEvents::TripItemEdited.into(), - ); - Ok((headers, trip_row(&state, trip_id, item_id).await?)) -} - -async fn trip_total_weight_htmx( - State(state): State, - Path(trip_id): Path, -) -> Result { - let total_weight = - models::trips::Trip::find_total_picked_weight(&state.database_pool, trip_id).await?; - Ok(view::trip::TripInfoTotalWeightRow::build( - trip_id, - total_weight, - )) -} - -#[derive(Deserialize)] -struct NewCategory { - #[serde(rename = "new-category-name")] - name: String, -} - -async fn inventory_category_create( - State(state): State, - Form(new_category): Form, -) -> Result { - if new_category.name.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let _new_id = - models::inventory::Category::save(&state.database_pool, &new_category.name).await?; - - Ok(Redirect::to("/inventory/")) -} - -async fn trip_state_set( - State(state): State, - headers: HeaderMap, - Path((trip_id, new_state)): Path<(Uuid, models::trips::TripState)>, -) -> Result { - let exists = models::trips::Trip::set_state(&state.database_pool, trip_id, &new_state).await?; - - if !exists { - return Err(Error::Request(RequestError::NotFound { - message: format!("trip with id {trip_id} not found"), - })); - } - - if is_htmx(&headers) { - Ok(view::trip::TripInfoStateRow::build(&new_state).into_response()) - } else { - Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)).into_response()) - } -} - -fn is_htmx(headers: &HeaderMap) -> bool { - headers - .get::(HtmxRequestHeaders::HtmxRequest.into()) - .map(|value| value == "true") - .unwrap_or(false) -} - -#[derive(Debug, Deserialize)] -struct TripTypeQuery { - edit: Option, -} - -async fn trips_types( - Extension(current_user): Extension, - State(mut state): State, - Query(trip_type_query): Query, -) -> Result { - state.client_state.trip_type_edit = trip_type_query.edit; - - let trip_types: Vec = - models::trips::TripsType::all(&state.database_pool).await?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::trip::types::TypeList::build(&state.client_state, trip_types), - Some(&TopLevelPage::Trips), - )) -} - -#[derive(Deserialize)] -struct NewTripType { - #[serde(rename = "new-trip-type-name")] - name: String, -} - -async fn trip_type_create( - State(state): State, - Form(new_trip_type): Form, -) -> Result { - if new_trip_type.name.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let _new_id = models::trips::TripsType::save(&state.database_pool, &new_trip_type.name).await?; - - Ok(Redirect::to("/trips/types/")) -} - -#[derive(Deserialize)] -struct TripTypeUpdate { - #[serde(rename = "new-value")] - new_value: String, -} - -async fn trips_types_edit_name( - State(state): State, - Path(trip_type_id): Path, - Form(trip_update): Form, -) -> Result { - if trip_update.new_value.is_empty() { - return Err(Error::Request(RequestError::EmptyFormElement { - name: "name".to_string(), - })); - } - - let exists = models::trips::TripsType::set_name( - &state.database_pool, - trip_type_id, - &trip_update.new_value, - ) - .await?; - - if !exists { - return Err(Error::Request(RequestError::NotFound { - message: format!("trip type with id {trip_type_id} not found"), - })); - } else { - Ok(Redirect::to("/trips/types/")) - } -} - -async fn inventory_item( - Extension(current_user): Extension, - State(state): State, - Path(id): Path, -) -> Result { - let item = models::inventory::InventoryItem::find(&state.database_pool, id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("inventory item with id {id} not found"), - }))?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::inventory::InventoryItem::build(&state.client_state, &item), - Some(&TopLevelPage::Inventory), - )) -} - -async fn trip_category_select( - State(state): State, - Path((trip_id, category_id)): Path<(Uuid, Uuid)>, -) -> Result { - let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("trip with id {trip_id} not found"), - }))?; - - trip.load_categories(&state.database_pool).await?; - - let active_category = trip - .categories() - .iter() - .find(|c| c.category.id == category_id) - .ok_or(Error::Request(RequestError::NotFound { - message: format!("category with id {category_id} not found"), - }))?; - - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::PushUrl.into(), - format!("?={category_id}").parse().unwrap(), - ); - - Ok(( - headers, - view::trip::TripItems::build(Some(active_category), &trip), - )) -} - -async fn inventory_category_select( - State(state): State, - Path(category_id): Path, -) -> Result { - let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - - let active_category: Option<&models::inventory::Category> = Some( - inventory - .categories - .iter() - .find(|category| category.id == category_id) - .ok_or(Error::Request(RequestError::NotFound { - message: format!("a category with id {category_id} not found"), - }))?, - ); - - let mut headers = HeaderMap::new(); - headers.insert::( - HtmxResponseHeaders::PushUrl.into(), - format!("/inventory/category/{category_id}/") - .parse() - .unwrap(), - ); - - Ok(( - headers, - view::inventory::Inventory::build( - active_category, - &inventory.categories, - state.client_state.edit_item, - ), - )) -} - -async fn trip_packagelist( - Extension(current_user): Extension, - State(state): State, - Path(trip_id): Path, -) -> Result { - let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("trip with id {trip_id} not found"), - }))?; - - trip.load_categories(&state.database_pool).await?; - - Ok(view::Root::build( - &Context::build(current_user), - &view::trip::packagelist::TripPackageList::build(&trip), - Some(&TopLevelPage::Trips), - )) -} - -async fn trip_item_packagelist_set_pack_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - true, - ) - .await?; - - let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("an item with id {item_id} does not exist"), - }))?; - - Ok(view::trip::packagelist::TripPackageListRowReady::build( - trip_id, &item, - )) -} - -async fn trip_item_packagelist_set_unpack_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Pack, - false, - ) - .await?; - - // note that this cannot fail due to a missing item, as trip_item_set_state would already - // return 404. but error handling cannot hurt ;) - let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("an item with id {item_id} does not exist"), - }))?; - - Ok(view::trip::packagelist::TripPackageListRowReady::build( - trip_id, &item, - )) -} - -async fn trip_item_packagelist_set_ready_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - true, - ) - .await?; - - let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("an item with id {item_id} does not exist"), - }))?; - - Ok(view::trip::packagelist::TripPackageListRowUnready::build( - trip_id, &item, - )) -} - -async fn trip_item_packagelist_set_unready_htmx( - State(state): State, - Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result { - trip_item_set_state( - &state, - trip_id, - item_id, - models::trips::TripItemStateKey::Ready, - false, - ) - .await?; - - // note that this cannot fail due to a missing item, as trip_item_set_state would already - // return 404. but error handling cannot hurt ;) - let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("an item with id {item_id} does not exist"), - }))?; - - Ok(view::trip::packagelist::TripPackageListRowUnready::build( - trip_id, &item, - )) -} diff --git a/rust/src/models/user.rs b/rust/src/models/user.rs index e6d73dc..96cd63e 100644 --- a/rust/src/models/user.rs +++ b/rust/src/models/user.rs @@ -8,6 +8,11 @@ pub struct User { pub fullname: String, } +pub struct NewUser<'a> { + pub username: &'a str, + pub fullname: &'a str, +} + #[derive(Debug)] pub struct DbUserRow { id: String, @@ -43,3 +48,22 @@ impl User { .transpose() } } + +pub async fn create(pool: &sqlx::Pool, user: NewUser<'_>) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + + sqlx::query!( + "INSERT INTO users + (id, username, fullname) + VALUES + (?, ?, ?)", + id_param, + user.username, + user.fullname + ) + .execute(pool) + .await?; + + Ok(id) +} diff --git a/rust/src/routing/mod.rs b/rust/src/routing/mod.rs new file mode 100644 index 0000000..362e451 --- /dev/null +++ b/rust/src/routing/mod.rs @@ -0,0 +1,153 @@ +use axum::{ + http::header::{HeaderMap, HeaderName}, + middleware, + routing::{get, post}, + Router, +}; + +use crate::{authorize, AppState, Error, RequestError, TopLevelPage}; + +mod routes; +use routes::*; + +enum HtmxResponseHeaders { + Trigger, + PushUrl, +} + +impl From for HeaderName { + fn from(val: HtmxResponseHeaders) -> Self { + match val { + HtmxResponseHeaders::Trigger => HeaderName::from_static("hx-trigger"), + HtmxResponseHeaders::PushUrl => HeaderName::from_static("hx-push-url"), + } + } +} + +enum HtmxRequestHeaders { + HtmxRequest, +} + +impl From for HeaderName { + fn from(val: HtmxRequestHeaders) -> Self { + match val { + HtmxRequestHeaders::HtmxRequest => HeaderName::from_static("hx-request"), + } + } +} + +fn is_htmx(headers: &HeaderMap) -> bool { + headers + .get::(HtmxRequestHeaders::HtmxRequest.into()) + .map(|value| value == "true") + .unwrap_or(false) +} + +fn get_referer<'a>(headers: &'a HeaderMap) -> Result<&'a str, Error> { + headers + .get("referer") + .ok_or(Error::Request(RequestError::RefererNotFound))? + .to_str() + .map_err(|error| { + Error::Request(RequestError::RefererInvalid { + message: error.to_string(), + }) + }) +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/favicon.svg", get(icon)) + .route("/assets/luggage.svg", get(icon)) + .route( + "/notfound", + get(|| async { + Error::Request(RequestError::NotFound { + message: "hi".to_string(), + }) + }), + ) + .route("/debug", get(debug)) + .merge( + // thse are routes that require authentication + Router::new() + .route("/", get(root)) + .nest( + (&TopLevelPage::Trips.path()).into(), + Router::new() + .route("/", get(trips).post(trip_create)) + .route("/types/", get(trips_types).post(trip_type_create)) + .route("/types/:id/edit/name/submit", post(trips_types_edit_name)) + .route("/:id/", get(trip)) + .route("/:id/comment/submit", post(trip_comment_set)) + .route("/:id/categories/:id/select", post(trip_category_select)) + .route("/:id/packagelist/", get(trip_packagelist)) + .route( + "/:id/packagelist/item/:id/pack", + post(trip_item_packagelist_set_pack_htmx), + ) + .route( + "/:id/packagelist/item/:id/unpack", + post(trip_item_packagelist_set_unpack_htmx), + ) + .route( + "/:id/packagelist/item/:id/ready", + post(trip_item_packagelist_set_ready_htmx), + ) + .route( + "/:id/packagelist/item/:id/unready", + post(trip_item_packagelist_set_unready_htmx), + ) + .route("/:id/state/:id", post(trip_state_set)) + .route("/:id/total_weight", get(trip_total_weight_htmx)) + .route("/:id/type/:id/add", get(trip_type_add)) + .route("/:id/type/:id/remove", get(trip_type_remove)) + .route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) + .route( + "/:id/items/:id/pick", + get(trip_item_set_pick).post(trip_item_set_pick_htmx), + ) + .route( + "/:id/items/:id/unpick", + get(trip_item_set_unpick).post(trip_item_set_unpick_htmx), + ) + .route( + "/:id/items/:id/pack", + get(trip_item_set_pack).post(trip_item_set_pack_htmx), + ) + .route( + "/:id/items/:id/unpack", + get(trip_item_set_unpack).post(trip_item_set_unpack_htmx), + ) + .route( + "/:id/items/:id/ready", + get(trip_item_set_ready).post(trip_item_set_ready_htmx), + ) + .route( + "/:id/items/:id/unready", + get(trip_item_set_unready).post(trip_item_set_unready_htmx), + ), + ) + .nest( + (&TopLevelPage::Inventory.path()).into(), + Router::new() + .route("/", get(inventory_inactive)) + .route("/categories/:id/select", post(inventory_category_select)) + .route("/category/", post(inventory_category_create)) + .route("/category/:id/", get(inventory_active)) + .route("/item/", post(inventory_item_create)) + .route("/item/:id/", get(inventory_item)) + .route("/item/:id/cancel", get(inventory_item_cancel)) + .route("/item/:id/delete", get(inventory_item_delete)) + .route("/item/:id/edit", post(inventory_item_edit)) + .route("/item/name/validate", post(inventory_item_validate_name)), + ) + .layer(middleware::from_fn_with_state(state.clone(), authorize)), + ) + .fallback(|| async { + Error::Request(RequestError::NotFound { + message: "no route found".to_string(), + }) + }) + .with_state(state) +} diff --git a/rust/src/routing/routes.rs b/rust/src/routing/routes.rs new file mode 100644 index 0000000..0bdad09 --- /dev/null +++ b/rust/src/routing/routes.rs @@ -0,0 +1,1026 @@ +use axum::{ + extract::{Extension, Path, Query, State}, + http::header::{self, HeaderMap, HeaderName}, + response::{IntoResponse, Redirect}, + Form, +}; + +use serde::Deserialize; +use uuid::Uuid; + +use crate::models; +use crate::view; +use crate::{html, AppState, Context, Error, HtmxEvents, RequestError, TopLevelPage}; + +use super::{get_referer, is_htmx, HtmxResponseHeaders}; + +#[derive(Deserialize, Default)] +pub struct InventoryQuery { + edit_item: Option, +} + +#[derive(Deserialize)] +pub struct NewItem { + #[serde(rename = "new-item-name")] + name: String, + #[serde(rename = "new-item-weight")] + weight: u32, + // damn i just love how serde is integrated everywhere, just add a feature to the uuid in + // cargo.toml and go + #[serde(rename = "new-item-category-id")] + category_id: Uuid, +} + +#[derive(Deserialize)] +pub struct NewItemName { + #[serde(rename = "new-item-name")] + name: String, +} + +#[derive(Deserialize)] +pub struct EditItem { + #[serde(rename = "edit-item-name")] + name: String, + #[serde(rename = "edit-item-weight")] + weight: u32, +} + +#[derive(Deserialize)] +pub struct NewTrip { + #[serde(rename = "new-trip-name")] + name: String, + #[serde(rename = "new-trip-start-date")] + date_start: time::Date, + #[serde(rename = "new-trip-end-date")] + date_end: time::Date, +} + +#[derive(Debug, Deserialize)] +pub struct TripQuery { + edit: Option, + category: Option, +} + +#[derive(Deserialize)] +pub struct CommentUpdate { + #[serde(rename = "new-comment")] + new_comment: String, +} + +#[derive(Deserialize)] +pub struct TripUpdate { + #[serde(rename = "new-value")] + new_value: String, +} + +#[derive(Deserialize)] +pub struct NewCategory { + #[serde(rename = "new-category-name")] + name: String, +} + +#[derive(Debug, Deserialize)] +pub struct TripTypeQuery { + edit: Option, +} + +#[derive(Deserialize)] +pub struct NewTripType { + #[serde(rename = "new-trip-type-name")] + name: String, +} + +#[derive(Deserialize)] +pub struct TripTypeUpdate { + #[serde(rename = "new-value")] + new_value: String, +} + +pub async fn root(Extension(current_user): Extension) -> impl IntoResponse { + view::Root::build( + &Context::build(current_user), + &view::home::Home::build(), + None, + ) +} + +pub async fn icon() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "image/svg+xml")], + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/luggage.svg")), + ) +} + +pub async fn debug(headers: HeaderMap) -> impl IntoResponse { + let out = { + let mut out = String::new(); + for (key, value) in headers.iter() { + out.push_str(&format!("{}: {}\n", key, value.to_str().unwrap())); + } + out + }; + out +} +pub async fn inventory_active( + Extension(current_user): Extension, + State(mut state): State, + Path(id): Path, + Query(inventory_query): Query, +) -> Result { + state.client_state.edit_item = inventory_query.edit_item; + state.client_state.active_category_id = Some(id); + + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; + + let active_category: Option<&models::inventory::Category> = state + .client_state + .active_category_id + .map(|id| { + inventory + .categories + .iter() + .find(|category| category.id == id) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("a category with id {id} does not exist"), + })) + }) + .transpose()?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::inventory::Inventory::build( + active_category, + &inventory.categories, + state.client_state.edit_item, + ), + Some(&TopLevelPage::Inventory), + )) +} + +pub async fn inventory_inactive( + Extension(current_user): Extension, + State(mut state): State, + Query(inventory_query): Query, +) -> Result { + state.client_state.edit_item = inventory_query.edit_item; + state.client_state.active_category_id = None; + + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::inventory::Inventory::build( + None, + &inventory.categories, + state.client_state.edit_item, + ), + Some(&TopLevelPage::Inventory), + )) +} + +pub async fn inventory_item_validate_name( + State(state): State, + Form(new_item): Form, +) -> Result { + let exists = + models::inventory::InventoryItem::name_exists(&state.database_pool, &new_item.name).await?; + + Ok(view::inventory::InventoryNewItemFormName::build( + Some(&new_item.name), + exists, + )) +} + +pub async fn inventory_item_create( + State(state): State, + headers: HeaderMap, + Form(new_item): Form, +) -> Result { + if new_item.name.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let _new_id = models::inventory::InventoryItem::save( + &state.database_pool, + &new_item.name, + new_item.category_id, + new_item.weight, + ) + .await?; + + if is_htmx(&headers) { + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; + + // it's impossible to NOT find the item here, as we literally just added + // it. + let active_category: Option<&models::inventory::Category> = Some( + inventory + .categories + .iter() + .find(|category| category.id == new_item.category_id) + .unwrap(), + ); + + Ok(view::inventory::Inventory::build( + active_category, + &inventory.categories, + state.client_state.edit_item, + ) + .into_response()) + } else { + Ok(Redirect::to(&format!( + "/inventory/category/{id}/", + id = new_item.category_id + )) + .into_response()) + } +} +pub async fn inventory_item_delete( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let deleted = models::inventory::InventoryItem::delete(&state.database_pool, id).await?; + + if !deleted { + Err(Error::Request(RequestError::NotFound { + message: format!("item with id {id} not found"), + })) + } else { + Ok(Redirect::to(get_referer(&headers)?)) + } +} + +pub async fn inventory_item_edit( + State(state): State, + Path(id): Path, + Form(edit_item): Form, +) -> Result { + if edit_item.name.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let id = models::inventory::InventoryItem::update( + &state.database_pool, + id, + &edit_item.name, + edit_item.weight, + ) + .await?; + + Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id))) +} + +pub async fn inventory_item_cancel( + State(state): State, + Path(id): Path, +) -> Result { + let id = models::inventory::InventoryItem::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("item with id {id} not found"), + }))?; + + Ok(Redirect::to(&format!( + "/inventory/category/{id}/", + id = id.category.id + ))) +} + +pub async fn trip_create( + State(state): State, + Form(new_trip): Form, +) -> Result { + if new_trip.name.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let new_id = models::trips::Trip::save( + &state.database_pool, + &new_trip.name, + new_trip.date_start, + new_trip.date_end, + ) + .await?; + + Ok(Redirect::to(&format!("/trips/{new_id}/"))) +} + +pub async fn trips( + Extension(current_user): Extension, + State(state): State, +) -> Result { + let trips = models::trips::Trip::all(&state.database_pool).await?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::trip::TripManager::build(trips), + Some(&TopLevelPage::Trips), + )) +} + +pub async fn trip( + Extension(current_user): Extension, + State(mut state): State, + Path(id): Path, + Query(trip_query): Query, +) -> Result { + state.client_state.trip_edit_attribute = trip_query.edit; + state.client_state.active_category_id = trip_query.category; + + let mut trip: models::trips::Trip = models::trips::Trip::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {id} not found"), + }))?; + + trip.load_trips_types(&state.database_pool).await?; + + trip.sync_trip_items_with_inventory(&state.database_pool) + .await?; + + trip.load_categories(&state.database_pool).await?; + + let active_category: Option<&models::trips::TripCategory> = state + .client_state + .active_category_id + .map(|id| { + trip.categories() + .iter() + .find(|category| category.category.id == id) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an active category with id {id} does not exist"), + })) + }) + .transpose()?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::trip::Trip::build( + &trip, + state.client_state.trip_edit_attribute, + active_category, + ), + Some(&TopLevelPage::Trips), + )) +} + +pub async fn trip_type_remove( + State(state): State, + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, +) -> Result { + let found = + models::trips::Trip::trip_type_remove(&state.database_pool, trip_id, type_id).await?; + + if !found { + Err(Error::Request(RequestError::NotFound { + message: format!("type {type_id} is not active for trip {trip_id}"), + })) + } else { + Ok(Redirect::to(&format!("/trips/{trip_id}/"))) + } +} + +pub async fn trip_type_add( + State(state): State, + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, +) -> Result { + models::trips::Trip::trip_type_add(&state.database_pool, trip_id, type_id).await?; + + Ok(Redirect::to(&format!("/trips/{trip_id}/"))) +} + +pub async fn trip_comment_set( + State(state): State, + Path(trip_id): Path, + Form(comment_update): Form, +) -> Result { + let found = models::trips::Trip::set_comment( + &state.database_pool, + trip_id, + &comment_update.new_comment, + ) + .await?; + + if !found { + Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })) + } else { + Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id))) + } +} + +pub async fn trip_edit_attribute( + State(state): State, + Path((trip_id, attribute)): Path<(Uuid, models::trips::TripAttribute)>, + Form(trip_update): Form, +) -> Result { + if attribute == models::trips::TripAttribute::Name { + if trip_update.new_value.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + } + models::trips::Trip::set_attribute( + &state.database_pool, + trip_id, + attribute, + &trip_update.new_value, + ) + .await?; + + Ok(Redirect::to(&format!("/trips/{trip_id}/"))) +} + +pub async fn trip_item_set_state( + state: &AppState, + trip_id: Uuid, + item_id: Uuid, + key: models::trips::TripItemStateKey, + value: bool, +) -> Result<(), Error> { + models::trips::TripItem::set_state(&state.database_pool, trip_id, item_id, key, value).await?; + Ok(()) +} + +pub async fn trip_row( + state: &AppState, + trip_id: Uuid, + item_id: Uuid, +) -> Result { + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) + .await? + .ok_or_else(|| { + Error::Request(RequestError::NotFound { + message: format!("item with id {item_id} not found for trip {trip_id}"), + }) + })?; + + let item_row = view::trip::TripItemListRow::build( + trip_id, + &item, + models::inventory::InventoryItem::get_category_max_weight( + &state.database_pool, + item.item.category_id, + ) + .await?, + ); + + let category = + models::trips::TripCategory::find(&state.database_pool, trip_id, item.item.category_id) + .await? + .ok_or_else(|| { + Error::Request(RequestError::NotFound { + message: format!("category with id {} not found", item.item.category_id), + }) + })?; + + // TODO biggest_category_weight? + let category_row = view::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true); + + Ok(html::concat(item_row, category_row)) +} + +pub async fn trip_item_set_pick( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pick, + true, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_pick_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pick, + true, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_item_set_unpick( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pick, + false, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_unpick_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pick, + false, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_item_set_pack( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + true, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_pack_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + true, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_item_set_unpack( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + false, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_unpack_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + false, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_item_set_ready( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + true, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_ready_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + true, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_item_set_unready( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + Ok::<_, Error>( + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + false, + ) + .await?, + ) + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? +} + +pub async fn trip_item_set_unready_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + false, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok((headers, trip_row(&state, trip_id, item_id).await?)) +} + +pub async fn trip_total_weight_htmx( + State(state): State, + Path(trip_id): Path, +) -> Result { + let total_weight = + models::trips::Trip::find_total_picked_weight(&state.database_pool, trip_id).await?; + Ok(view::trip::TripInfoTotalWeightRow::build( + trip_id, + total_weight, + )) +} + +pub async fn inventory_category_create( + State(state): State, + Form(new_category): Form, +) -> Result { + if new_category.name.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let _new_id = + models::inventory::Category::save(&state.database_pool, &new_category.name).await?; + + Ok(Redirect::to("/inventory/")) +} + +pub async fn trip_state_set( + State(state): State, + headers: HeaderMap, + Path((trip_id, new_state)): Path<(Uuid, models::trips::TripState)>, +) -> Result { + let exists = models::trips::Trip::set_state(&state.database_pool, trip_id, &new_state).await?; + + if !exists { + return Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })); + } + + if is_htmx(&headers) { + Ok(view::trip::TripInfoStateRow::build(&new_state).into_response()) + } else { + Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)).into_response()) + } +} +pub async fn trips_types( + Extension(current_user): Extension, + State(mut state): State, + Query(trip_type_query): Query, +) -> Result { + state.client_state.trip_type_edit = trip_type_query.edit; + + let trip_types: Vec = + models::trips::TripsType::all(&state.database_pool).await?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::trip::types::TypeList::build(&state.client_state, trip_types), + Some(&TopLevelPage::Trips), + )) +} +pub async fn trip_type_create( + State(state): State, + Form(new_trip_type): Form, +) -> Result { + if new_trip_type.name.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let _new_id = models::trips::TripsType::save(&state.database_pool, &new_trip_type.name).await?; + + Ok(Redirect::to("/trips/types/")) +} +pub async fn trips_types_edit_name( + State(state): State, + Path(trip_type_id): Path, + Form(trip_update): Form, +) -> Result { + if trip_update.new_value.is_empty() { + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); + } + + let exists = models::trips::TripsType::set_name( + &state.database_pool, + trip_type_id, + &trip_update.new_value, + ) + .await?; + + if !exists { + return Err(Error::Request(RequestError::NotFound { + message: format!("trip type with id {trip_type_id} not found"), + })); + } else { + Ok(Redirect::to("/trips/types/")) + } +} + +pub async fn inventory_item( + Extension(current_user): Extension, + State(state): State, + Path(id): Path, +) -> Result { + let item = models::inventory::InventoryItem::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("inventory item with id {id} not found"), + }))?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::inventory::InventoryItem::build(&state.client_state, &item), + Some(&TopLevelPage::Inventory), + )) +} + +pub async fn trip_category_select( + State(state): State, + Path((trip_id, category_id)): Path<(Uuid, Uuid)>, +) -> Result { + let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + }))?; + + trip.load_categories(&state.database_pool).await?; + + let active_category = trip + .categories() + .iter() + .find(|c| c.category.id == category_id) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("category with id {category_id} not found"), + }))?; + + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::PushUrl.into(), + format!("?={category_id}").parse().unwrap(), + ); + + Ok(( + headers, + view::trip::TripItems::build(Some(active_category), &trip), + )) +} + +pub async fn inventory_category_select( + State(state): State, + Path(category_id): Path, +) -> Result { + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; + + let active_category: Option<&models::inventory::Category> = Some( + inventory + .categories + .iter() + .find(|category| category.id == category_id) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("a category with id {category_id} not found"), + }))?, + ); + + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::PushUrl.into(), + format!("/inventory/category/{category_id}/") + .parse() + .unwrap(), + ); + + Ok(( + headers, + view::inventory::Inventory::build( + active_category, + &inventory.categories, + state.client_state.edit_item, + ), + )) +} + +pub async fn trip_packagelist( + Extension(current_user): Extension, + State(state): State, + Path(trip_id): Path, +) -> Result { + let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + }))?; + + trip.load_categories(&state.database_pool).await?; + + Ok(view::Root::build( + &Context::build(current_user), + &view::trip::packagelist::TripPackageList::build(&trip), + Some(&TopLevelPage::Trips), + )) +} + +pub async fn trip_item_packagelist_set_pack_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + true, + ) + .await?; + + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; + + Ok(view::trip::packagelist::TripPackageListRowReady::build( + trip_id, &item, + )) +} + +pub async fn trip_item_packagelist_set_unpack_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Pack, + false, + ) + .await?; + + // note that this cannot fail due to a missing item, as trip_item_set_state would already + // return 404. but error handling cannot hurt ;) + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; + + Ok(view::trip::packagelist::TripPackageListRowReady::build( + trip_id, &item, + )) +} + +pub async fn trip_item_packagelist_set_ready_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + true, + ) + .await?; + + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; + + Ok(view::trip::packagelist::TripPackageListRowUnready::build( + trip_id, &item, + )) +} + +pub async fn trip_item_packagelist_set_unready_htmx( + State(state): State, + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, +) -> Result { + trip_item_set_state( + &state, + trip_id, + item_id, + models::trips::TripItemStateKey::Ready, + false, + ) + .await?; + + // note that this cannot fail due to a missing item, as trip_item_set_state would already + // return 404. but error handling cannot hurt ;) + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; + + Ok(view::trip::packagelist::TripPackageListRowUnready::build( + trip_id, &item, + )) +} diff --git a/rust/src/view/mod.rs b/rust/src/view/mod.rs index 12afc4c..6115ec3 100644 --- a/rust/src/view/mod.rs +++ b/rust/src/view/mod.rs @@ -98,7 +98,7 @@ impl Root { ."px-5" ."bg-gray-200" ."hover:bg-gray-300" - href=(format!("/user/{}", context.user.username)) + href=(format!("/user/{}", context.user.id)) { span ."m-auto" @@ -106,7 +106,7 @@ impl Root { ."mdi-account" ."text-3xl" {} - p { (context.user.username)} + p { (context.user.fullname)} } } (body)