diff --git a/api/Cargo.toml b/api/Cargo.toml index 550c156..360e7cd 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -28,7 +28,7 @@ features = ["full"] [dependencies.serde] version = "1.0" -features = ["derive"] +features = ["derive", "rc"] [dependencies.uuid] version = "0.8" diff --git a/api/src/db.rs b/api/src/db.rs index a7678a8..87a697e 100644 --- a/api/src/db.rs +++ b/api/src/db.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::rc::Rc; + use rusqlite; use uuid::Uuid; @@ -12,6 +15,8 @@ use super::PreparationStep; use super::Trip; use super::TripParameters; use super::TripState; +use super::TripItem; +use super::TripItemStatus; pub fn load() -> rusqlite::Result<()> { let example_lists = vec![ @@ -726,6 +731,20 @@ pub fn load() -> rusqlite::Result<()> { [], )?; + conn.execute( + "CREATE TABLE trip_items ( + trip_id TEXT NOT NULL, + packagelistitem_id TEXT NOT NULL, + status INTEGER NOT NULL, + + FOREIGN KEY(trip_id) REFERENCES trips(id) + FOREIGN KEY(packagelistitem_id) REFERENCES packagelistitems(id) + + PRIMARY KEY(trip_id, packagelistitem_id) + )", + [], + )?; + for list in example_lists { conn.execute( "INSERT INTO @@ -1040,3 +1059,220 @@ pub fn get_trips() -> rusqlite::Result> { Ok(trips) } + +pub fn get_trip(trip_id: Uuid) -> rusqlite::Result> { + let conn = rusqlite::Connection::open("./sqlite.db")?; + + let mut statement = conn.prepare( + "SELECT + trips.id AS trip_id, + trips.name AS trip_name, + trips.date AS trip_date, + trips.days AS trip_days, + trips.state AS trip_state + FROM trips + WHERE + trip_id = ?1 + ", + )?; + + let trip_query_result = statement.query_map(rusqlite::params![trip_id], |row| { + Ok(Trip{ + id: row.get_unwrap(0), + name: row.get_unwrap(1), + date: row.get_unwrap(2), + parameters: TripParameters{ + days: row.get_unwrap(3), + }, + package_lists: vec![], + state: row.get_unwrap(4), + }) + })?; + + let mut trip_query_result: Vec = trip_query_result.flatten().collect(); + + if trip_query_result.is_empty() { + return Ok(None); + } + + let mut trip: Trip = trip_query_result.remove(0); + + let mut statement = conn.prepare( + "SELECT + trip_packagelist.packagelist_id as package_list_id, + packagelists.name as package_list_name + FROM trip_packagelist + INNER JOIN packagelists + ON packagelists.id == trip_packagelist.packagelist_id + WHERE + trip_packagelist.trip_id = ?1 + + ", + )?; + + let lists: Vec<(Uuid, String)> = statement + .query_map(rusqlite::params![trip_id], |row| { + Ok((row.get_unwrap(0), row.get_unwrap(1))) + })? + .flatten() + .collect(); + + trip.set_package_lists(lists); + + Ok(Some(trip)) +} + +pub fn get_trip_items(trip_id: Uuid) -> rusqlite::Result>> { + // there has to be better way to get the rested option result + let trip: Option = get_trip(trip_id)?; + println!("Getting trip items"); + if let None = trip { + return Ok(None) + } + let trip = trip.unwrap(); + + let conn = rusqlite::Connection::open("./sqlite.db")?; + + let mut statement = conn.prepare( + "SELECT + trip_items.packagelistitem_id, + status AS status, + pli.id AS packageitem_id, + pli.name AS packageitem_name, + pli.count AS packageitem_count, + FROM trip_items + INNER JOIN packagelistitems as pli + ON trip_items.packagelistitem_id = pli.id + WHERE + trip_items.trip_id = ?1 + ", + )?; + + let mut result : Vec = Vec::new(); + + let trip_items : Vec = statement.query_map(rusqlite::params![trip_id], |row| { + let package_item = PackageItem::new( + row.get_unwrap(2), + row.get_unwrap(3), + ItemSize::None, + row.get_unwrap(4), + ItemUsage::Singleton, + Preparation::None, + ); + + let package_list_id = row.get_unwrap(5); + + let package_list = Rc::new(PackageList::new( + package_list_id, + row.get_unwrap(6), + )); + + Ok(TripItem{ + status: row.get_unwrap(1), + package_item, + package_list: Rc::clone(package_lists.get(&package_list_id).unwrap()), + }) + })?.flatten().collect(); + + println!("Found {} trip items in database", trip_items.len()); + // get all package list items, so we know what we SHOULD have + let mut package_list_items = vec![]; + for package_list in &trip.package_lists { + package_list_items.append(&mut get_list_items(package_list.id)?); + } + + println!("There should be {} items according to the package lists", package_list_items.len()); + + let package_list_item_ids: Vec = package_list_items.iter().map(|item| item.id).collect(); + + // three possibilities: + // + // * The trip item is there => use it + // * The trip item is not there => create it with defaults + // * There is a trip item that should no be there (e.g. because the package + // list was changed and a package item was removed) + // + // We can either just not use it (letting it rot in the database). It will + // never be removed automatically. But if we later add it again, something + // will break (e.g. if someone re-attaches the package list to the trip). + + // first run: get all trip items that have the trip id set. We need to + // check this list against the items. If there are any returned that are + // NOT part of the package list items, we remove them + for package_item in package_list_items { + + + for trip_item in &trip_items { + let item_id = trip_item.package_item.id; + if !package_list_item_ids.contains(&item_id) { + // there is a trip item that does no longer have a corresponding + // package list item (because a package list was detached). + // Remove it + println!("Would remove package item with id {} (\"{}\")", + package_item.id, package_item.name); + // conn.execute( + // "DELETE + // FROM trip_items + // WHERE + // trip_items.trip_id = ?1 + // AND + // trip_items.packagelistitem_id = ?2 + // ", + // rusqlite::params![trip_id, package_item.id] + // )?; + continue + } + } + + if !trip_items.iter().map(|i| i.package_item.id).collect::>().contains(&package_item.id) { + println!("Found new trip item!"); + + println!("Looking into hashmap with {:?}", package_item.id); + let list = Rc::clone(package_lists.get(&package_item.id).unwrap()); + let new_trip_item = TripItem { + status: TripItemStatus::Pending, + package_item, + package_list: list, + }; + + conn.execute( + "INSERT INTO + trip_items ( + trip_id, + packagelistitem_id, + status + ) VALUES ( + ?1, + ?2, + ?3 + ) + ", + rusqlite::params![ + trip_id, + new_trip_item.package_item.id, + new_trip_item.status + ] + )?; + + + result.push(new_trip_item); + continue; + } + + // If we've come so far, the trip is actually in the database, so let's + // just use it + println!("Using trip item from database"); + let list = Rc::clone(package_lists.get(&package_item.id).unwrap()); + result.push(TripItem { + status: TripItemStatus::Pending, + package_item, + package_list: list, + }); + + } + + + + + Ok(Some(result)) +} diff --git a/api/src/lib.rs b/api/src/lib.rs index 73acf88..3745767 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -19,6 +19,8 @@ pub mod trip; pub use trip::Trip; pub use trip::TripParameters; pub use trip::TripState; +pub use trip::TripItem; +pub use trip::TripItemStatus; pub fn get_list(id: Uuid) -> Option { self::db::get_list(id).unwrap() @@ -51,3 +53,11 @@ pub fn new_item(list_id: Uuid, item_name: String, item_count: i32) -> packagelis pub fn get_trips() -> Vec { self::db::get_trips().unwrap() } + +pub fn get_trip(trip_id: Uuid) -> Option { + self::db::get_trip(trip_id).unwrap() +} + +pub fn get_trip_items(trip_id: Uuid) -> Option> { + self::db::get_trip_items(trip_id).unwrap() +} diff --git a/api/src/router.rs b/api/src/router.rs index 6856f27..12745b3 100644 --- a/api/src/router.rs +++ b/api/src/router.rs @@ -131,6 +131,40 @@ pub fn new() -> warp::filters::BoxedFilter<(impl warp::Reply,)> { .map(|| warp::reply::json(&super::get_trips())) .with(&cors); + let trip = warp::path!("v1" / "trips" / String) + .and(warp::path::end()) + .and(warp::get()) + .and(accept_json) + .and_then(|trip_id: String| async move { + match Uuid::parse_str(&trip_id) { + Ok(trip_id) => { + match &super::get_trip(trip_id) { + Some(trip) => Ok(warp::reply::json(trip)), + None => Err(warp::reject::not_found()), + } + }, + Err(_) => Err(warp::reject::custom(InvalidUuid)), + } + }) + .with(&cors); + + let trip_items = warp::path!("v1" / "trips" / String / "items") + .and(warp::path::end()) + .and(warp::get()) + .and(accept_json) + .and_then(|trip_id: String| async move { + match Uuid::parse_str(&trip_id) { + Ok(trip_id) => { + match &super::get_trip_items(trip_id) { + Some(trip) => Ok(warp::reply::json(trip)), + None => Err(warp::reject::not_found()), + } + }, + Err(_) => Err(warp::reject::custom(InvalidUuid)), + } + }) + .with(&cors); + root.or(v1) .or(lists) .or(list) @@ -138,6 +172,8 @@ pub fn new() -> warp::filters::BoxedFilter<(impl warp::Reply,)> { .or(list_items) .or(preparation) .or(trips) + .or(trip) + .or(trip_items) .recover(handle_rejection) .boxed() } diff --git a/api/src/trip.rs b/api/src/trip.rs index 172b268..8e2188b 100644 --- a/api/src/trip.rs +++ b/api/src/trip.rs @@ -1,6 +1,11 @@ +use std::rc::Rc; + use serde::Serialize; use uuid::Uuid; +use crate::PackageItem; +use crate::PackageList; + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub enum TripItemStatus { @@ -9,6 +14,36 @@ pub enum TripItemStatus { Packed, } +impl rusqlite::types::FromSql for TripItemStatus { + fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { + match value.as_i64()? { + 1 => Ok(TripItemStatus::Pending), + 2 => Ok(TripItemStatus::Ready), + 3 => Ok(TripItemStatus::Packed), + v => Err(rusqlite::types::FromSqlError::OutOfRange(v)), + } + } +} + +impl rusqlite::types::ToSql for TripItemStatus { + fn to_sql(&self) -> rusqlite::Result { + let v = rusqlite::types::Value::Integer(match self { + TripItemStatus::Pending => 1, + TripItemStatus::Ready => 2, + TripItemStatus::Packed => 3, + }); + rusqlite::Result::Ok(rusqlite::types::ToSqlOutput::Owned(v)) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TripItem { + pub status: TripItemStatus, + pub package_item: PackageItem, + pub package_list: Rc, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TripParameters { diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 03fef9e..3937403 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -8,6 +8,8 @@ import PackageList from "./routes/PackageList.svelte"; import Preparation from "./routes/Preparation.svelte"; import Trips from "./routes/Trips.svelte"; + import Trip from "./routes/Trip.svelte"; + import NotFound from "./routes/NotFound.svelte"; function normalize(path) { return path.replace(/\/+$/, '') + "/"; @@ -32,6 +34,10 @@ } else if (urlParts[0] == "trips" && urlParts.length == 1) { console.log("=> Trips"); currentRoute = Trips; + } else if (urlParts[0] == "trips" && urlParts.length == 2) { + console.log("=> Trip"); + currentRoute = Trip; + data = {id: urlParts[1]}; } else if (urlParts[0] == "lists" && urlParts.length == 2) { console.log("=> PackageList"); currentRoute = PackageList; @@ -45,6 +51,7 @@ data = {list_id: urlParts[1], item_id: urlParts[3]}; } else { console.log("No matching route found"); + currentRoute = NotFound; } } diff --git a/ui/src/routes/NotFound.svelte b/ui/src/routes/NotFound.svelte new file mode 100644 index 0000000..a8dbba8 --- /dev/null +++ b/ui/src/routes/NotFound.svelte @@ -0,0 +1,10 @@ + + +
+
+ 404 -- Not Found +
+ +
diff --git a/ui/src/routes/Trip.svelte b/ui/src/routes/Trip.svelte new file mode 100644 index 0000000..8cdff42 --- /dev/null +++ b/ui/src/routes/Trip.svelte @@ -0,0 +1,78 @@ + + +
+
+ {#await getTrip()} +

Loading

+ {:then trip} +

{trip.name}

+ + + + + + + + + + + + + + +
Date{trip.date}
Duration{trip.parameters.days} Days
Status{trip.state}
+ + + + + + + + + + {#await getTripItems()} +

Loading

+ {:then items} + {#each items as item} + + + + + {/each} + {:catch error} + {error} + {/await} + +
NameStatus
{item.packageItem.name}{item.status}
+ {/await} +
+
diff --git a/ui/src/routes/Trips.svelte b/ui/src/routes/Trips.svelte index 20049e7..9b99350 100644 --- a/ui/src/routes/Trips.svelte +++ b/ui/src/routes/Trips.svelte @@ -36,7 +36,7 @@ {:then trips} {#each trips as trip} redirect(url + trip.id)}> - {trip.name} + redirect(`/trips/${trip.id}`)}>{trip.name} {trip.date} {trip.parameters.days} {#if trip.state == "active"}