diff --git a/README.md b/README.md index ea24d09..91ff09c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The application is based on [axum](https://github.com/tokio-rs/axum): ## Features/TODO: * [x] create invoice + validation * [ ] create user + authentication -* [ ] list invoices +* [x] list invoices * [ ] edit invoice * [ ] ratelimits * [ ] generate pdf diff --git a/src/api/invoices.rs b/src/api/invoices.rs index b24cccb..38effad 100644 --- a/src/api/invoices.rs +++ b/src/api/invoices.rs @@ -79,7 +79,7 @@ pub struct CreateInvoiceAttachment { } /// A populated invoice type that is returned to the user -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct PopulatedInvoice { pub id: i32, pub status: crate::models::InvoiceStatus, @@ -146,3 +146,7 @@ pub async fn create( axum::Json(conn.create_invoice(multipart.data.clone()).await?), )) } + +pub async fn list_all(mut conn: DatabaseConnection) -> Result>, Error> { + Ok(axum::Json(conn.list_invoices().await?)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3eff668..9f78f1f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::DefaultBodyLimit, - routing::{get, post}, - Router, -}; +use axum::{extract::DefaultBodyLimit, routing::get, Router}; use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer}; pub mod invoices; @@ -10,7 +6,7 @@ pub mod invoices; pub fn app() -> Router { Router::new() .route("/health", get(health)) - .route("/invoices", post(invoices::create)) + .route("/invoices", get(invoices::list_all).post(invoices::create)) .layer(TraceLayer::new_for_http()) .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new( diff --git a/src/database/invoices.rs b/src/database/invoices.rs index b1f75f8..8bd7101 100644 --- a/src/database/invoices.rs +++ b/src/database/invoices.rs @@ -2,6 +2,7 @@ use super::DatabaseConnection; use crate::api::invoices::{CreateInvoice, PopulatedInvoice}; use crate::error::Error; use crate::models::*; +use futures::TryStreamExt; use diesel::prelude::*; use diesel_async::RunQueryDsl; @@ -103,4 +104,49 @@ impl DatabaseConnection { attachments, }) } + pub async fn list_invoices(&mut self) -> Result, Error> { + let (invoices, parties): (Vec, Vec) = { + use crate::schema::invoices; + use crate::schema::parties; + invoices::table + .inner_join(parties::table) + .select((Invoice::as_select(), Party::as_select())) + .load_stream::<(Invoice, Party)>(&mut self.0) + .await? + .try_fold( + (Vec::new(), Vec::new()), + |(mut invoices, mut parties), (invoice, party)| { + invoices.push(invoice); + parties.push(party); + futures::future::ready(Ok((invoices, parties))) + }, + ) + .await? + }; + let invoice_rows = InvoiceRow::belonging_to(&invoices) + .select(InvoiceRow::as_select()) + .load(&mut self.0) + .await? + .grouped_by(&invoices); + let attachments = Attachment::belonging_to(&invoices) + .select(Attachment::as_select()) + .load(&mut self.0) + .await? + .grouped_by(&invoices); + Ok(invoice_rows + .into_iter() + .zip(attachments) + .zip(invoices) + .zip(parties) + .map(|(((rows, attachments), invoice), party)| PopulatedInvoice { + id: invoice.id, + status: invoice.status, + creation_time: invoice.creation_time, + counter_party: party, + rows, + due_date: invoice.due_date, + attachments, + }) + .collect::>()) + } } diff --git a/src/models.rs b/src/models.rs index 639eeb6..4541187 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2,6 +2,7 @@ use crate::schema::{invoice_attachments, invoice_rows, invoices, parties}; use chrono::{DateTime, NaiveDate, Utc}; use garde::Validate; +use diesel::prelude::*; use serde_derive::{Deserialize, Serialize}; // NOTES: @@ -14,7 +15,7 @@ use serde_derive::{Deserialize, Serialize}; // - Is VAT really necessary to account for? I'm leaving it out for now // - I'm also leaving InvoiceType out, at least for now -#[derive(diesel_derive_enum::DbEnum, Debug, Clone, Copy, Serialize)] +#[derive(diesel_derive_enum::DbEnum, Debug, Clone, Copy, Serialize, Deserialize)] #[ExistingTypePath = "crate::schema::sql_types::InvoiceStatus"] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum InvoiceStatus { @@ -24,7 +25,7 @@ pub enum InvoiceStatus { } /// A party of the invoice -#[derive(Identifiable, Queryable, Clone, Debug, Serialize)] +#[derive(Identifiable, Queryable, Selectable, Clone, Debug, Serialize, Deserialize)] #[diesel(table_name = parties)] pub struct Party { pub id: i32, @@ -61,7 +62,8 @@ pub struct NewParty { } /// The invoice model as stored in the database -#[derive(Identifiable, Queryable, Clone, Debug)] +#[derive(Identifiable, Queryable, Selectable, Associations, Clone, Debug)] +#[diesel(belongs_to(Party, foreign_key = counter_party_id))] #[diesel(table_name = invoices)] pub struct Invoice { pub id: i32, @@ -81,12 +83,15 @@ pub struct NewInvoice { } /// A single row of an invoice -#[derive(Identifiable, Queryable, Clone, Debug, Serialize)] +#[derive( + Identifiable, Queryable, Selectable, Associations, Clone, Debug, Serialize, Deserialize, +)] +#[diesel(belongs_to(Invoice))] #[diesel(table_name = invoice_rows)] pub struct InvoiceRow { - #[serde(skip_serializing)] + #[serde(skip_serializing, skip_deserializing)] pub id: i32, - #[serde(skip_serializing)] + #[serde(skip_serializing, skip_deserializing)] pub invoice_id: i32, /// The product can be at most 128 characters pub product: String, @@ -110,12 +115,15 @@ pub struct NewInvoiceRow { /// The metadata for an invoice attachment /// The file itself can be requested using its hash and filename /// => /somepath/{hash}/{filename} -#[derive(Identifiable, Queryable, Clone, Debug, Serialize)] +#[derive( + Identifiable, Queryable, Selectable, Associations, Clone, Debug, Serialize, Deserialize, +)] +#[diesel(belongs_to(Invoice))] #[diesel(table_name = invoice_attachments)] pub struct Attachment { - #[serde(skip_serializing)] + #[serde(skip_serializing, skip_deserializing)] pub id: i32, - #[serde(skip_serializing)] + #[serde(skip_serializing, skip_deserializing)] pub invoice_id: i32, /// The filename can be at most 128 characters pub filename: String, diff --git a/src/tests/invoices.rs b/src/tests/invoices.rs index 699ef98..c4fba27 100644 --- a/src/tests/invoices.rs +++ b/src/tests/invoices.rs @@ -1,5 +1,5 @@ use crate::api::app; -use crate::api::invoices::{CreateInvoice, CreateInvoiceRow}; +use crate::api::invoices::{CreateInvoice, CreateInvoiceRow, PopulatedInvoice}; use crate::models::NewParty; use axum::http::StatusCode; @@ -49,3 +49,61 @@ async fn create() { assert_eq!(response.status_code(), StatusCode::CREATED); } + +#[tokio::test] +async fn list_all() { + let app = app().with_state(crate::database::new().await); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/invoices").await; + assert_eq!(response.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn create_list_all() { + let app = app().with_state(crate::database::new().await); + + let body = CreateInvoice { + counter_party: NewParty { + name: String::from("Velkoja"), + street: String::from("Otakaari"), + city: String::from("Espoo"), + zip: String::from("02jotain"), + bank_account: String::from("ei ole"), + }, + due_date: chrono::Local::now().date_naive(), + rows: vec![ + CreateInvoiceRow { + product: String::from("pleikkari"), + quantity: 69, + unit: String::from("kpl"), + unit_price: 4200, + }, + CreateInvoiceRow { + product: String::from("xbox"), + quantity: 1, + unit: String::from("kpl"), + unit_price: 4200, + }, + CreateInvoiceRow { + product: String::from("nintendo wii"), + quantity: 2, + unit: String::from("kpl"), + unit_price: 4200, + }, + ], + attachments: vec![], + }; + + let server = TestServer::new(app).unwrap(); + let body = MultipartForm::new().add_text("data", serde_json::to_string(&body).unwrap()); + + let create_response = server.post("/invoices").multipart(body).await; + assert_eq!(create_response.status_code(), StatusCode::CREATED); + + let list_response = server.get("/invoices").await; + assert_eq!(list_response.status_code(), StatusCode::OK); + + let result = std::panic::catch_unwind(|| list_response.json::>()); + assert!(result.is_ok_and(|invoices| !invoices.is_empty())); +}