diff --git a/.sqlx/query-33cba5d1fbcfb492f8f8443782c45f9326e3fa966b3f8e864b3e01d4fe7b25b8.json b/.sqlx/query-33cba5d1fbcfb492f8f8443782c45f9326e3fa966b3f8e864b3e01d4fe7b25b8.json new file mode 100644 index 0000000..58d9808 --- /dev/null +++ b/.sqlx/query-33cba5d1fbcfb492f8f8443782c45f9326e3fa966b3f8e864b3e01d4fe7b25b8.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "UPDATE trip_todos\n SET description = ?\n WHERE \n id = ? \n AND trip_id = ?\n AND EXISTS(SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)\n RETURNING\n id,\n description,\n done\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "done", + "ordinal": 2, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "33cba5d1fbcfb492f8f8443782c45f9326e3fa966b3f8e864b3e01d4fe7b25b8" +} diff --git a/.sqlx/query-5ac0e60ed79f626300f0dfde880f92d4eae3aa4281eafc2ac29fdb83525e0536.json b/.sqlx/query-5ac0e60ed79f626300f0dfde880f92d4eae3aa4281eafc2ac29fdb83525e0536.json new file mode 100644 index 0000000..c6e4e7e --- /dev/null +++ b/.sqlx/query-5ac0e60ed79f626300f0dfde880f92d4eae3aa4281eafc2ac29fdb83525e0536.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM trip_todos\n WHERE \n id = ?\n AND EXISTS (SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "5ac0e60ed79f626300f0dfde880f92d4eae3aa4281eafc2ac29fdb83525e0536" +} diff --git a/.sqlx/query-9784595191d25448b2a24c856288d8fa3ba73c423cafcaa555c6f0a588b622a3.json b/.sqlx/query-9784595191d25448b2a24c856288d8fa3ba73c423cafcaa555c6f0a588b622a3.json new file mode 100644 index 0000000..6c99c3e --- /dev/null +++ b/.sqlx/query-9784595191d25448b2a24c856288d8fa3ba73c423cafcaa555c6f0a588b622a3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO trip_todos\n (id, description, done, trip_id)\n SELECT ?, ?, false, id as trip_id\n FROM trips\n WHERE trip_id = ? AND EXISTS(SELECT 1 FROM trips WHERE id = ? and user_id = ?)\n LIMIT 1", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "9784595191d25448b2a24c856288d8fa3ba73c423cafcaa555c6f0a588b622a3" +} diff --git a/Cargo.toml b/Cargo.toml index 555765a..6bdf174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,14 @@ name = "packager" path = "src/main.rs" [features] -jaeger = [] -prometheus = [] -tokio-console = [] +jaeger = [ + "dep:opentelemetry", + "dep:tracing-opentelemetry", + "dep:opentelemetry-jaeger", + "tokio/tracing" +] +prometheus = ["dep:axum-prometheus"] +tokio-console = ["dep:console-subscriber"] default = ["jaeger", "prometheus", "tokio-console"] @@ -20,9 +25,11 @@ lto = "off" [dependencies.opentelemetry] version = "0.20" +optional = true [dependencies.tracing-opentelemetry] version = "0.21" +optional = true [dependencies.tracing-log] version = "0.1" @@ -30,6 +37,7 @@ version = "0.1" [dependencies.opentelemetry-jaeger] version = "0.19" features = ["rt-tokio"] +optional = true [dependencies.http] version = "0.2" @@ -47,10 +55,11 @@ features = ["headers"] [dependencies.tokio] version = "1" -features = ["macros", "rt-multi-thread", "tracing"] +features = ["macros", "rt-multi-thread"] [dependencies.console-subscriber] version = "0.1" +optional = true [dependencies.hyper] version = "0.14" @@ -58,6 +67,7 @@ features = ["full"] [dependencies.tower] version = "0.4" +features = ["timeout"] [dependencies.tower-http] version = "0.4" @@ -114,6 +124,7 @@ version = "0.1" [dependencies.axum-prometheus] version = "0.4" +optional = true [dependencies.metrics] version = "0.21" diff --git a/src/models/trips/todos.rs b/src/models/trips/todos.rs index f0a7782..6938aa3 100644 --- a/src/models/trips/todos.rs +++ b/src/models/trips/todos.rs @@ -1,3 +1,4 @@ +use maud::{html, Markup}; use uuid::Uuid; use crate::{ @@ -5,6 +6,8 @@ use crate::{ sqlite, Context, }; +use super::Trip; + #[derive(Debug, PartialEq, Eq)] pub enum State { Todo, @@ -173,4 +176,408 @@ impl Todo { }) }) } + + pub async fn new( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + description: String, + ) -> Result { + let user_id = ctx.user.id.to_string(); + let id = Uuid::new_v4(); + tracing::info!("adding new todo with id {id}"); + let id_param = id.to_string(); + let trip_id_param = trip_id.to_string(); + crate::execute!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Insert, + component: sqlite::Component::Todo, + }, + pool, + "INSERT INTO trip_todos + (id, description, done, trip_id) + SELECT ?, ?, false, id as trip_id + FROM trips + WHERE trip_id = ? AND EXISTS(SELECT 1 FROM trips WHERE id = ? and user_id = ?) + LIMIT 1", + id_param, + description, + trip_id_param, + trip_id_param, + user_id, + ) + .await?; + + Ok(id) + } + + #[tracing::instrument] + pub async fn delete( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + id: Uuid, + ) -> Result { + let id_param = id.to_string(); + let user_id = ctx.user.id.to_string(); + let trip_id_param = trip_id.to_string(); + let results = crate::execute!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Delete, + component: sqlite::Component::Todo, + }, + pool, + "DELETE FROM trip_todos + WHERE + id = ? + AND EXISTS (SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)", + id_param, + trip_id_param, + user_id, + ) + .await?; + + Ok(results.rows_affected() != 0) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum TodoUiState { + Default, + Edit, +} + +impl Todo { + #[tracing::instrument] + pub fn build(&self, trip_id: &Uuid, state: TodoUiState) -> Markup { + let done = self.is_done(); + html!( + li + ."flex" + ."flex-row" + ."justify-start" + ."items-stretch" + ."bg-green-50"[done] + ."bg-red-50"[!done] + ."h-full" + { + @if state == TodoUiState::Edit { + form + name="edit-todo" + id="edit-todo" + action={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/edit/save" + } + target="_self" + method="post" + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/edit/save" + } + hx-target="closest li" + hx-swap="outerHTML" + {} + div + ."flex" + ."flex-row" + ."aspect-square" + { + span + ."mdi" + ."m-auto" + ."text-xl" + ."mdi-check"[self.is_done()] + ."mdi-checkbox-blank-outline"[!self.is_done()] + {} + } + div + ."p-2" + .grow + { + input + ."w-full" + type="text" + form="edit-todo" + id="todo-description" + name="todo-description" + value=(self.description) + {} + } + button + type="submit" + form="edit-todo" + ."bg-green-200" + ."hover:bg-green-300" + ."flex" + ."flex-row" + ."aspect-square" + { + span + ."mdi" + ."m-auto" + ."mdi-content-save" + ."text-xl" + {} + } + a + href="." + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/edit/cancel" + } + hx-target="closest li" + hx-swap="outerHTML" + ."flex" + ."flex-row" + ."aspect-square" + ."bg-red-200" + ."hover:bg-red-300" + { + span + ."mdi" + ."mdi-cancel" + ."text-xl" + ."m-auto" + {} + } + } @else { + @if done { + a + ."flex" + ."flex-row" + ."aspect-square" + ."hover:bg-red-50" + href={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/undone" + } + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/undone" + } + hx-target="closest li" + hx-swap="outerHTML" + { + span + ."mdi" + ."m-auto" + ."text-xl" + ."mdi-check" + {} + } + } @else { + a + ."flex" + ."flex-row" + ."aspect-square" + ."hover:bg-green-50" + href={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/done" + } + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/done" + } + hx-target="closest li" + hx-swap="outerHTML" + { + span + ."mdi" + ."m-auto" + ."text-xl" + ."mdi-checkbox-blank-outline" + {} + } + } + span + ."p-2" + ."grow" + { + (self.description) + } + a + ."flex" + ."flex-row" + ."aspect-square" + ."bg-blue-200" + ."hover:bg-blue-400" + href=(format!("?edit_todo={id}", id = self.id)) + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/edit" + } + hx-target="closest li" + hx-swap="outerHTML" + { + span ."m-auto" ."mdi" ."mdi-pencil" ."text-xl" {} + } + a + ."flex" + ."flex-row" + ."aspect-square" + ."bg-red-100" + ."hover:bg-red-200" + href=(format!("?delete_todo={id}", id = self.id)) + hx-post={ + "/trips/" (trip_id) + "/todo/" (self.id) + "/delete" + } + hx-target="#todolist" + hx-swap="outerHTML" + { + span ."m-auto" ."mdi" ."mdi-delete-outline" ."text-xl" {} + } + } + } + ) + } + + #[tracing::instrument] + pub async fn set_description( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + todo_id: Uuid, + new_description: String, + ) -> Result, Error> { + let user_id = ctx.user.id.to_string(); + let trip_id_param = trip_id.to_string(); + let todo_id_param = todo_id.to_string(); + + let result = crate::query_one!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Update, + component: sqlite::Component::Todo, + }, + pool, + TodoRow, + Todo, + "UPDATE trip_todos + SET description = ? + WHERE + id = ? + AND trip_id = ? + AND EXISTS(SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?) + RETURNING + id, + description, + done + ", + new_description, + todo_id_param, + trip_id_param, + trip_id_param, + user_id, + ) + .await?; + + Ok(result) + } +} + +pub struct NewTodo; + +impl NewTodo { + #[tracing::instrument] + pub fn build(trip_id: &Uuid) -> Markup { + html!( + li + ."flex" + ."flex-row" + ."justify-start" + ."items-stretch" + ."h-full" + { + form + name="new-todo" + id="new-todo" + action={ + "/trips/" (trip_id) + "/todo/new" + } + target="_self" + method="post" + hx-post={ + "/trips/" (trip_id) + "/todo/new" + } + hx-target="#todolist" + hx-swap="outerHTML" + {} + button + type="submit" + form="new-todo" + ."bg-green-200" + ."hover:bg-green-300" + ."flex" + ."flex-row" + ."aspect-square" + { + span + ."mdi" + ."m-auto" + ."mdi-plus" + ."text-xl" + {} + } + div + ."border-4" + ."p-1" + .grow + { + input + ."appearance-none" + ."w-full" + type="text" + form="new-todo" + id="new-todo-description" + name="new-todo-description" + {} + } + } + ) + } +} + +#[derive(Debug)] +pub struct TodoList<'a> { + pub trip: &'a Trip, + pub todos: &'a Vec, +} + +impl<'a> TodoList<'a> { + #[tracing::instrument] + pub fn build(&self, edit_todo: Option) -> Markup { + html!( + div #todolist { + h1 ."text-xl" ."mb-5" { "Todos" } + ul + ."flex" + ."flex-col" + { + @for todo in self.todos { + @let state = edit_todo + .map(|id| if todo.id == id { + TodoUiState::Edit + } else { + TodoUiState::Default + }).unwrap_or(TodoUiState::Default); + (todo.build(&self.trip.id, state)) + } + (NewTodo::build(&self.trip.id)) + } + } + ) + } } diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 43d97f3..21ce935 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -147,7 +147,12 @@ pub fn router(state: AppState) -> Router { .route( "/:id/todo/:id/undone", get(trip_todo_undone).post(trip_todo_undone_htmx), - ), + ) + .route("/:id/todo/:id/edit", post(trip_todo_edit)) + .route("/:id/todo/:id/edit/save", post(trip_todo_edit_save)) + .route("/:id/todo/:id/edit/cancel", post(trip_todo_edit_cancel)) + .route("/:id/todo/new", post(trip_todo_new)) + .route("/:id/todo/:id/delete", post(trip_todo_delete)), ) .nest( (&TopLevelPage::Inventory.path()).into(), diff --git a/src/routing/routes.rs b/src/routing/routes.rs index ca635e8..ebb999a 100644 --- a/src/routing/routes.rs +++ b/src/routing/routes.rs @@ -73,6 +73,8 @@ pub struct NewTrip { pub struct TripQuery { edit: Option, category: Option, + edit_todo: Option, + delete_todo: Option, } #[derive(Deserialize, Debug)] @@ -424,11 +426,25 @@ pub async fn trip( State(mut state): State, Path(id): Path, Query(trip_query): Query, + headers: HeaderMap, ) -> Result { let ctx = Context::build(current_user); state.client_state.trip_edit_attribute = trip_query.edit; state.client_state.active_category_id = trip_query.category; + if let Some(delete_todo) = trip_query.delete_todo { + let deleted = + models::trips::todos::Todo::delete(&ctx, &state.database_pool, id, delete_todo).await?; + + return if deleted { + Ok(Redirect::to(get_referer(&headers)?).into_response()) + } else { + Err(Error::Request(RequestError::NotFound { + message: format!("todo with id {id} not found"), + })) + }; + } + let mut trip: models::trips::Trip = models::trips::Trip::find(&ctx, &state.database_pool, id) .await? .ok_or(Error::Request(RequestError::NotFound { @@ -463,9 +479,11 @@ pub async fn trip( &trip, state.client_state.trip_edit_attribute.as_ref(), active_category, + trip_query.edit_todo, ), Some(&TopLevelPage::Trips), - )) + ) + .into_response()) } #[tracing::instrument] @@ -1265,7 +1283,7 @@ pub async fn trip_todo_done_htmx( }) })?; - Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) + Ok(todo_item.build(&trip_id, models::trips::todos::TodoUiState::Default)) } #[tracing::instrument] @@ -1312,7 +1330,7 @@ pub async fn trip_todo_undone_htmx( }) })?; - Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) + Ok(todo_item.build(&trip_id, models::trips::todos::TodoUiState::Default)) } #[tracing::instrument] @@ -1334,3 +1352,162 @@ pub async fn trip_todo_undone( Ok(Redirect::to(get_referer(&headers)?)) } + +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct TripTodoDescription { + #[serde(rename = "todo-description")] + description: String, +} + +#[tracing::instrument] +pub async fn trip_todo_edit( + Extension(current_user): Extension, + State(state): State, + headers: HeaderMap, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, +) -> Result { + let ctx = Context::build(current_user); + let todo_item = + models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id).await?; + + match todo_item { + None => Err(Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + })), + Some(todo_item) => Ok(todo_item + .build(&trip_id, models::trips::todos::TodoUiState::Edit) + .into_response()), + } +} + +#[tracing::instrument] +pub async fn trip_todo_edit_save( + Extension(current_user): Extension, + State(state): State, + headers: HeaderMap, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, + Form(form): Form, +) -> Result { + let ctx = Context::build(current_user); + let todo_item = models::trips::todos::Todo::set_description( + &ctx, + &state.database_pool, + trip_id, + todo_id, + form.description, + ) + .await?; + + match todo_item { + None => Err(Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + })), + Some(todo_item) => { + if htmx::is_htmx(&headers) { + Ok(todo_item + .build(&trip_id, models::trips::todos::TodoUiState::Default) + .into_response()) + } else { + Ok(Redirect::to(&format!("/trips/{trip_id}/")).into_response()) + } + } + } +} + +#[tracing::instrument] +pub async fn trip_todo_edit_cancel( + Extension(current_user): Extension, + State(state): State, + headers: HeaderMap, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, +) -> Result { + let ctx = Context::build(current_user); + let todo_item = + models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id).await?; + + match todo_item { + None => Err(Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + })), + Some(todo_item) => Ok(todo_item + .build(&trip_id, models::trips::todos::TodoUiState::Default) + .into_response()), + } +} + +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct TripTodoNew { + #[serde(rename = "new-todo-description")] + description: String, +} + +#[tracing::instrument] +pub async fn trip_todo_new( + Extension(current_user): Extension, + State(state): State, + headers: HeaderMap, + Path(trip_id): Path, + Form(form): Form, +) -> Result { + let ctx = Context::build(current_user); + // method output is not required as we reload the whole trip todos anyway + let _todo_item = + models::trips::todos::Todo::new(&ctx, &state.database_pool, trip_id, form.description) + .await?; + + if htmx::is_htmx(&headers) { + let trip = models::trips::Trip::find(&ctx, &state.database_pool, trip_id).await?; + match trip { + None => Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })), + Some(mut trip) => { + trip.load_todos(&ctx, &state.database_pool).await?; + Ok(models::trips::todos::TodoList { + trip: &trip, + todos: &trip.todos(), + } + .build(None) + .into_response()) + } + } + } else { + Ok(Redirect::to(&format!("/trips/{trip_id}/")).into_response()) + } +} + +#[tracing::instrument] +pub async fn trip_todo_delete( + Extension(current_user): Extension, + State(state): State, + headers: HeaderMap, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, +) -> Result { + let ctx = Context::build(current_user); + let deleted = + models::trips::todos::Todo::delete(&ctx, &state.database_pool, trip_id, todo_id).await?; + + if !deleted { + return Err(Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + })); + } + + let trip = models::trips::Trip::find(&ctx, &state.database_pool, trip_id).await?; + match trip { + None => Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })), + Some(mut trip) => { + trip.load_todos(&ctx, &state.database_pool).await?; + Ok(models::trips::todos::TodoList { + trip: &trip, + todos: &trip.todos(), + } + .build(None) + .into_response()) + } + } +} diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs index 55d406c..ece0994 100644 --- a/src/telemetry/mod.rs +++ b/src/telemetry/mod.rs @@ -1,2 +1,3 @@ +#[cfg(feature = "prometheus")] pub mod metrics; pub mod tracing; diff --git a/src/view/trip/mod.rs b/src/view/trip/mod.rs index 143bd94..4e9a9a1 100644 --- a/src/view/trip/mod.rs +++ b/src/view/trip/mod.rs @@ -257,6 +257,7 @@ impl Trip { trip: &models::trips::Trip, trip_edit_attribute: Option<&models::trips::TripAttribute>, active_category: Option<&models::trips::TripCategory>, + edit_todo: Option, ) -> Markup { html!( div ."p-8" ."flex" ."flex-col" ."gap-8" { @@ -370,7 +371,7 @@ impl Trip { } } (TripInfo::build(trip_edit_attribute, trip)) - (TripTodoList::build(trip)) + (crate::models::trips::todos::TodoList{todos: trip.todos(), trip: &trip}.build(edit_todo)) (TripComment::build(trip)) (TripItems::build(active_category, trip)) } @@ -795,113 +796,6 @@ impl TripInfo { } } -pub struct TripTodo; - -impl TripTodo { - #[tracing::instrument] - pub fn build(trip_id: &Uuid, todo: &models::trips::todos::Todo) -> Markup { - let done = todo.is_done(); - html!( - li - ."flex" - ."flex-row" - ."justify-start" - ."items-stretch" - ."bg-green-50"[done] - ."bg-red-50"[!done] - ."hover:bg-white"[!done] - ."h-full" - { - @if done { - a - ."flex" - ."flex-row" - ."aspect-square" - href={ - "/trips/" (trip_id) - "/todo/" (todo.id) - "/undone" - } - hx-post={ - "/trips/" (trip_id) - "/todo/" (todo.id) - "/undone" - } - hx-target="closest li" - hx-swap="outerHTML" - { - span - ."mdi" - ."m-auto" - ."text-xl" - ."mdi-check" - {} - } - } @else { - a - ."flex" - ."flex-row" - ."aspect-square" - href={ - "/trips/" (trip_id) - "/todo/" (todo.id) - "/done" - } - hx-post={ - "/trips/" (trip_id) - "/todo/" (todo.id) - "/done" - } - hx-target="closest li" - hx-swap="outerHTML" - { - span - ."mdi" - ."m-auto" - ."text-xl" - ."mdi-checkbox-blank-outline" - {} - } - } - span - ."p-2" - { - (todo.description) - } - } - ) - } -} - -pub struct TripTodoList; - -impl TripTodoList { - #[tracing::instrument] - pub fn build(trip: &models::trips::Trip) -> Markup { - let todos = trip.todos(); - html!( - div { - h1 ."text-xl" ."mb-5" { "Todos" } - - @if todos.is_empty() { - p { "no todos" } - - } @else { - - ul - ."flex" - ."flex-col" - { - @for todo in trip.todos() { - (TripTodo::build(&trip.id, &todo)) - } - } - } - } - ) - } -} - pub struct TripComment; impl TripComment {