Skip to content

Commit

Permalink
feat: allow listing all invoices
Browse files Browse the repository at this point in the history
  • Loading branch information
jooakar authored Feb 12, 2024
1 parent 9d5dc7a commit a84ee7f
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 18 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/api/invoices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Json<Vec<PopulatedInvoice>>, Error> {
Ok(axum::Json(conn.list_invoices().await?))
}
8 changes: 2 additions & 6 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
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;

pub fn app() -> Router<crate::database::State> {
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(
Expand Down
46 changes: 46 additions & 0 deletions src/database/invoices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,4 +104,49 @@ impl DatabaseConnection {
attachments,
})
}
pub async fn list_invoices(&mut self) -> Result<Vec<PopulatedInvoice>, Error> {
let (invoices, parties): (Vec<Invoice>, Vec<Party>) = {
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::<Vec<PopulatedInvoice>>())
}
}
26 changes: 17 additions & 9 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
60 changes: 59 additions & 1 deletion src/tests/invoices.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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::<Vec<PopulatedInvoice>>());
assert!(result.is_ok_and(|invoices| !invoices.is_empty()));
}

0 comments on commit a84ee7f

Please sign in to comment.