Refactor backend
* Reorganizing code into more logical modules: - `routes` specifically for Rocket handler functions - `receipts` data model for receipts - `transactions` for Firefly transactions * Encapsulate database operations for receipts using the repository pattern; move SQL queries to external files (`sqlx` can only use string literals or external files for queries, not variables or constants) * Remove obsolete routes, templates for old transaction-focused pagesbugfix/ci-buildah
parent
16701a6313
commit
5c7225f077
|
@ -4,4 +4,5 @@
|
|||
!js/
|
||||
!migrations/
|
||||
!src/
|
||||
!sql/
|
||||
!templates/
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nINSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n$1, $2, $3, $4, $5, $6\n)\nRETURNING id\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Numeric",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "16d204cf990ebd5f78cfe21a5a9cbcfc9b0f084b755d0d9f065dbfe31c53679d"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n",
|
||||
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -46,5 +46,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "e62b8704ebb897be63463427ba9e808ddb9c7fb5d3326b388d5e7f1066732aa9"
|
||||
"hash": "71dcdc6a24d99eff2dd7af673a0ebb6fda45b0ebd5244309472921a934e1b829"
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n $1, $2, $3, $4, $5, $6\n)\nRETURNING\n id, vendor, date, amount, notes, filename\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "vendor",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "date",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Numeric"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "notes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "filename",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Numeric",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a3fb24d192843a656565cebcc78a49f5cd14598e029d6bacfeb2326daf142119"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
||||
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -48,5 +48,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "9fe465619a9818b201fbf091018a75eea38e846468d1877062db8c5606550a13"
|
||||
"hash": "e173b8a98dcdf9e4b259ae59e0ddcceef07851bbe9b429fd62b9d02bf01f52e1"
|
||||
}
|
|
@ -14,6 +14,7 @@ COPY Cargo.* .
|
|||
COPY src src
|
||||
COPY .sqlx .sqlx
|
||||
COPY migrations migrations
|
||||
COPY sql sql
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cargo \
|
||||
cargo build --release --locked
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
WHERE
|
||||
id = $1
|
|
@ -0,0 +1,7 @@
|
|||
INSERT INTO receipts (
|
||||
vendor, date, amount, notes, filename, image
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
RETURNING
|
||||
id, vendor, date, amount, notes, filename
|
|
@ -0,0 +1,5 @@
|
|||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
ORDER BY date
|
241
src/main.rs
241
src/main.rs
|
@ -1,25 +1,21 @@
|
|||
mod config;
|
||||
mod firefly;
|
||||
mod receipts;
|
||||
mod routes;
|
||||
mod transactions;
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use rocket::fairing::{self, AdHoc};
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::{FileServer, TempFile};
|
||||
use rocket::http::Status;
|
||||
use rocket::fs::FileServer;
|
||||
use rocket::response::{Redirect, Responder};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||
use rocket::{Rocket, State};
|
||||
use rocket::Rocket;
|
||||
use rocket_db_pools::Database as RocketDatabase;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use rocket_dyn_templates::Template;
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use config::Config;
|
||||
use firefly::{
|
||||
Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate,
|
||||
};
|
||||
use firefly::Firefly;
|
||||
|
||||
struct Context {
|
||||
#[allow(dead_code)]
|
||||
|
@ -46,21 +42,6 @@ impl Context {
|
|||
#[database("receipts")]
|
||||
struct Database(rocket_db_pools::sqlx::PgPool);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Transaction {
|
||||
pub id: String,
|
||||
pub amount: f64,
|
||||
pub description: String,
|
||||
pub date: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
#[derive(rocket::FromForm)]
|
||||
struct TransactionPostData<'r> {
|
||||
amount: f64,
|
||||
notes: String,
|
||||
photo: Vec<TempFile<'r>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
|
@ -82,207 +63,11 @@ impl ErrorResponse {
|
|||
}
|
||||
}
|
||||
|
||||
impl TryFrom<TransactionRead> for Transaction {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> {
|
||||
let first_split = match t.attributes.transactions.first() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("Invalid transaction {}: no splits", t.id);
|
||||
return Err("Transaction has no splits");
|
||||
},
|
||||
};
|
||||
let date = first_split.date;
|
||||
let amount = t.amount();
|
||||
let description = if let Some(title) = &t.attributes.group_title {
|
||||
title.into()
|
||||
} else {
|
||||
first_split.description.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
id: t.id,
|
||||
amount,
|
||||
description,
|
||||
date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/")]
|
||||
async fn index() -> Redirect {
|
||||
Redirect::to("/receipts")
|
||||
}
|
||||
|
||||
#[rocket::get("/transactions", format = "html", rank = 2)]
|
||||
async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
|
||||
let result = ctx
|
||||
.firefly
|
||||
.search_transactions(&ctx.config.firefly.search_query)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let transactions: Vec<_> = r
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|t| match Transaction::try_from(t) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
error!("Error parsing transaction details: {}", e);
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
Status::Ok,
|
||||
Template::render(
|
||||
"transaction-list",
|
||||
context! {
|
||||
transactions: transactions,
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error fetching transaction list: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Template::render(
|
||||
"error",
|
||||
context! {
|
||||
error: "Failed to fetch transaction list from Firefly III",
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/transactions", format = "json")]
|
||||
async fn transaction_list_json(
|
||||
ctx: &State<Context>,
|
||||
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
|
||||
let result = ctx
|
||||
.firefly
|
||||
.search_transactions(&ctx.config.firefly.search_query)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error fetching transaction list: {}", e);
|
||||
ErrorResponse::new(
|
||||
"Failed to fetch transaction list from Firefly III",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(
|
||||
result
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|t| match Transaction::try_from(t) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
error!("Error parsing transaction details: {}", e);
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[rocket::get("/transactions/<id>")]
|
||||
async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
|
||||
match ctx.firefly.get_transaction(id).await {
|
||||
Ok(t) => match Transaction::try_from(t.data) {
|
||||
Ok(t) => return Some(Template::render("transaction", t)),
|
||||
Err(e) => {
|
||||
error!("Invalid transaction {}: {}", id, e);
|
||||
},
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to get transaction {}: {}", id, e);
|
||||
},
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[rocket::post("/transactions/<id>", data = "<form>")]
|
||||
async fn update_transaction(
|
||||
id: &str,
|
||||
form: Form<TransactionPostData<'_>>,
|
||||
ctx: &State<Context>,
|
||||
) -> (Status, String) {
|
||||
let txn = match ctx.firefly.get_transaction(id).await {
|
||||
Ok(t) => t.data,
|
||||
Err(e) => {
|
||||
error!("Failed to get transaction {}: {}", id, e);
|
||||
return (Status::NotFound, format!("Invalid transaction {}", id));
|
||||
},
|
||||
};
|
||||
let amount = txn.amount();
|
||||
if amount != form.amount && txn.attributes.transactions.len() != 1 {
|
||||
return (
|
||||
Status::BadRequest,
|
||||
"Cannot update the amount of a split transaction".into(),
|
||||
);
|
||||
}
|
||||
let mut splits: Vec<_> = txn
|
||||
.attributes
|
||||
.transactions
|
||||
.into_iter()
|
||||
.map(|s| TransactionSplitUpdate {
|
||||
transaction_journal_id: s.transaction_journal_id,
|
||||
amount: s.amount,
|
||||
notes: s.notes,
|
||||
})
|
||||
.collect();
|
||||
let jrnl_id = if let Some(split) = splits.last_mut() {
|
||||
if form.amount != amount {
|
||||
split.amount = form.amount.to_string();
|
||||
}
|
||||
if !form.notes.is_empty() {
|
||||
split.notes = Some(form.notes.clone());
|
||||
}
|
||||
split.transaction_journal_id.clone()
|
||||
} else {
|
||||
error!("Somehow, transaction {} has no splits!", id);
|
||||
return (Status::BadRequest, "Invalid transaction: no splits".into());
|
||||
};
|
||||
let update = TransactionUpdate {
|
||||
transactions: splits,
|
||||
};
|
||||
if let Err(e) = ctx.firefly.update_transaction(id, &update).await {
|
||||
error!("Failed to update transaction {}: {}", id, e);
|
||||
return (Status::BadRequest, e.to_string());
|
||||
}
|
||||
for file in &form.photo[..] {
|
||||
let stream = match file.open().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!("Failed to open uploaded file: {}", e);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut content = Vec::new();
|
||||
if let Err(e) = reader.read_to_end(&mut content).await {
|
||||
error!("Failed to read uploaded file: {}", e);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = ctx
|
||||
.firefly
|
||||
.attach_receipt(
|
||||
&jrnl_id,
|
||||
content,
|
||||
file.name().unwrap_or("receipt.jpg"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to attach receipt to transaction: {}", e);
|
||||
}
|
||||
}
|
||||
(Status::Ok, "Successfully updated transaction".into())
|
||||
}
|
||||
|
||||
async fn run_migrations(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
||||
if let Some(db) = Database::fetch(&rocket) {
|
||||
info!("Applying database migrations");
|
||||
|
@ -320,17 +105,9 @@ async fn rocket() -> _ {
|
|||
|
||||
rocket
|
||||
.manage(ctx)
|
||||
.mount(
|
||||
"/",
|
||||
rocket::routes![
|
||||
index,
|
||||
transaction_list,
|
||||
transaction_list_json,
|
||||
get_transaction,
|
||||
update_transaction
|
||||
],
|
||||
)
|
||||
.mount("/receipts", receipts::routes())
|
||||
.mount("/", rocket::routes![index])
|
||||
.mount("/transactions", routes::transactions::routes())
|
||||
.mount("/receipts", routes::receipts::routes())
|
||||
.mount("/static", FileServer::from("static"))
|
||||
.attach(Template::fairing())
|
||||
.attach(Database::init())
|
||||
|
|
227
src/receipts.rs
227
src/receipts.rs
|
@ -1,17 +1,12 @@
|
|||
use chrono::{DateTime, FixedOffset};
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::http::{ContentType, MediaType, Status};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||
use rocket::Route;
|
||||
use rocket_db_pools::Connection as DatabaseConnection;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use rocket_db_pools::Connection;
|
||||
use serde::Serialize;
|
||||
use sqlx::types::Decimal;
|
||||
use tracing::{error, info};
|
||||
use tracing::error;
|
||||
|
||||
use super::Database;
|
||||
use crate::Database;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Receipt {
|
||||
|
@ -43,7 +38,7 @@ pub struct ReceiptPostForm<'r> {
|
|||
pub photo: TempFile<'r>,
|
||||
}
|
||||
|
||||
struct ReceiptPostData {
|
||||
pub struct ReceiptPostData {
|
||||
pub date: DateTime<FixedOffset>,
|
||||
pub vendor: String,
|
||||
pub amount: Decimal,
|
||||
|
@ -53,7 +48,7 @@ struct ReceiptPostData {
|
|||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ReceiptPostFormError {
|
||||
pub enum ReceiptPostFormError {
|
||||
#[error("Invalid date: {0}")]
|
||||
Date(#[from] chrono::format::ParseError),
|
||||
#[error("Invalid amount: {0}")]
|
||||
|
@ -77,7 +72,7 @@ pub enum DeleteReceiptResponse {
|
|||
}
|
||||
|
||||
impl ReceiptPostData {
|
||||
async fn from_form(
|
||||
pub async fn from_form(
|
||||
form: &ReceiptPostForm<'_>,
|
||||
) -> Result<Self, ReceiptPostFormError> {
|
||||
let date = DateTime::parse_from_str(
|
||||
|
@ -109,72 +104,29 @@ impl ReceiptPostData {
|
|||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/")]
|
||||
pub async fn list_receipts(
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> (Status, Template) {
|
||||
let result = rocket_db_pools::sqlx::query_as!(
|
||||
ReceiptJson,
|
||||
r##"
|
||||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
ORDER BY date
|
||||
"##,
|
||||
)
|
||||
.fetch_all(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => (
|
||||
Status::Ok,
|
||||
Template::render(
|
||||
"receipt-list",
|
||||
context! {
|
||||
receipts: r,
|
||||
},
|
||||
),
|
||||
),
|
||||
Err(e) => (
|
||||
Status::InternalServerError,
|
||||
Template::render(
|
||||
"error",
|
||||
context! {
|
||||
error: e.to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
pub struct ReceiptsRepository {
|
||||
conn: Connection<Database>,
|
||||
}
|
||||
impl ReceiptsRepository {
|
||||
pub fn new(conn: Connection<Database>) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/add")]
|
||||
pub async fn receipt_form() -> Template {
|
||||
Template::render("receipt-form", context! {})
|
||||
}
|
||||
pub async fn list_receipts(
|
||||
&mut self,
|
||||
) -> Result<Vec<ReceiptJson>, sqlx::Error> {
|
||||
sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql")
|
||||
.fetch_all(&mut **self.conn)
|
||||
.await
|
||||
}
|
||||
|
||||
#[rocket::post("/add", data = "<form>")]
|
||||
pub async fn add_receipt(
|
||||
form: Form<ReceiptPostForm<'_>>,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> (Status, Json<AddReceiptResponse>) {
|
||||
let data = match ReceiptPostData::from_form(&form).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
Status::BadRequest,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
);
|
||||
},
|
||||
};
|
||||
let result = rocket_db_pools::sqlx::query!(
|
||||
r##"
|
||||
INSERT INTO receipts (
|
||||
vendor, date, amount, notes, filename, image
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
RETURNING id
|
||||
"##,
|
||||
pub async fn add_receipt(
|
||||
&mut self,
|
||||
data: &ReceiptPostData,
|
||||
) -> Result<ReceiptJson, sqlx::Error> {
|
||||
let result = sqlx::query_file_as!(
|
||||
ReceiptJson,
|
||||
"sql/receipts/insert-receipt.sql",
|
||||
data.vendor,
|
||||
data.date,
|
||||
data.amount,
|
||||
|
@ -182,120 +134,39 @@ RETURNING id
|
|||
data.filename,
|
||||
data.photo,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
info!("Created new receipt {}", r.id);
|
||||
(
|
||||
Status::Ok,
|
||||
Json(AddReceiptResponse::Success(ReceiptJson {
|
||||
id: r.id,
|
||||
vendor: data.vendor,
|
||||
date: data.date,
|
||||
amount: data.amount,
|
||||
notes: Some(data.notes),
|
||||
filename: data.filename,
|
||||
})),
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to insert new receipt record: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
)
|
||||
},
|
||||
.fetch_one(&mut **self.conn)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/<id>")]
|
||||
pub async fn get_receipt(
|
||||
pub async fn get_receipt(
|
||||
&mut self,
|
||||
id: i32,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> Option<Template> {
|
||||
let result = rocket_db_pools::sqlx::query_as!(
|
||||
ReceiptJson,
|
||||
r##"
|
||||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
WHERE
|
||||
id = $1
|
||||
"##,
|
||||
id,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => Some(Template::render("receipt", r)),
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
) -> Result<ReceiptJson, sqlx::Error> {
|
||||
sqlx::query_file_as!(ReceiptJson, "sql/receipts/get-receipt.sql", id,)
|
||||
.fetch_one(&mut **self.conn)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/<id>/view/<filename>")]
|
||||
pub async fn view_receipt_photo(
|
||||
pub async fn get_receipt_photo(
|
||||
&mut self,
|
||||
id: i32,
|
||||
#[allow(unused_variables)] filename: &str,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> Option<(ContentType, Vec<u8>)> {
|
||||
let result = rocket_db_pools::sqlx::query!(
|
||||
) -> Result<(String, Vec<u8>), sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
"SELECT filename, image FROM receipts WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let mt = r
|
||||
.filename
|
||||
.rsplit_once('.')
|
||||
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
||||
.unwrap_or(MediaType::Binary);
|
||||
Some((ContentType(mt), r.image))
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
.fetch_one(&mut **self.conn)
|
||||
.await?;
|
||||
Ok((result.filename, result.image))
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::delete("/<id>")]
|
||||
pub async fn delete_receipt(
|
||||
pub async fn delete_receipt(
|
||||
&mut self,
|
||||
id: i32,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> (Status, Json<DeleteReceiptResponse>) {
|
||||
let result = rocket_db_pools::sqlx::query_as!(
|
||||
ReceiptJson,
|
||||
"DELETE FROM receipts WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Json(DeleteReceiptResponse::Error(e.to_string())),
|
||||
)
|
||||
},
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query_as!(ReceiptJson, "DELETE FROM receipts WHERE id = $1", id)
|
||||
.execute(&mut **self.conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
rocket::routes![
|
||||
add_receipt,
|
||||
delete_receipt,
|
||||
get_receipt,
|
||||
list_receipts,
|
||||
receipt_form,
|
||||
view_receipt_photo,
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
pub mod receipts;
|
||||
pub mod transactions;
|
|
@ -0,0 +1,137 @@
|
|||
use rocket::form::Form;
|
||||
use rocket::http::{ContentType, MediaType, Status};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket_db_pools::Connection as DatabaseConnection;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::receipts::*;
|
||||
use crate::Database;
|
||||
|
||||
#[rocket::get("/")]
|
||||
pub async fn list_receipts(
|
||||
db: DatabaseConnection<Database>,
|
||||
) -> (Status, Template) {
|
||||
let mut repo = ReceiptsRepository::new(db);
|
||||
match repo.list_receipts().await {
|
||||
Ok(r) => (
|
||||
Status::Ok,
|
||||
Template::render(
|
||||
"receipt-list",
|
||||
context! {
|
||||
receipts: r,
|
||||
},
|
||||
),
|
||||
),
|
||||
Err(e) => (
|
||||
Status::InternalServerError,
|
||||
Template::render(
|
||||
"error",
|
||||
context! {
|
||||
error: e.to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/add")]
|
||||
pub async fn receipt_form() -> Template {
|
||||
Template::render("receipt-form", context! {})
|
||||
}
|
||||
|
||||
#[rocket::post("/add", data = "<form>")]
|
||||
pub async fn add_receipt(
|
||||
form: Form<ReceiptPostForm<'_>>,
|
||||
db: DatabaseConnection<Database>,
|
||||
) -> (Status, Json<AddReceiptResponse>) {
|
||||
let data = match ReceiptPostData::from_form(&form).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
Status::BadRequest,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut repo = ReceiptsRepository::new(db);
|
||||
match repo.add_receipt(&data).await {
|
||||
Ok(r) => {
|
||||
info!("Created new receipt {}", r.id);
|
||||
(Status::Ok, Json(AddReceiptResponse::Success(r)))
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to insert new receipt record: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/<id>")]
|
||||
pub async fn get_receipt(
|
||||
id: i32,
|
||||
db: DatabaseConnection<Database>,
|
||||
) -> Option<Template> {
|
||||
let mut repo = ReceiptsRepository::new(db);
|
||||
match repo.get_receipt(id).await {
|
||||
Ok(r) => Some(Template::render("receipt", r)),
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/<id>/view/<_>")]
|
||||
pub async fn view_receipt_photo(
|
||||
id: i32,
|
||||
db: DatabaseConnection<Database>,
|
||||
) -> Option<(ContentType, Vec<u8>)> {
|
||||
let mut repo = ReceiptsRepository::new(db);
|
||||
match repo.get_receipt_photo(id).await {
|
||||
Ok((filename, image)) => {
|
||||
let mt = filename
|
||||
.rsplit_once('.')
|
||||
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
||||
.unwrap_or(MediaType::Binary);
|
||||
Some((ContentType(mt), image))
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::delete("/<id>")]
|
||||
pub async fn delete_receipt(
|
||||
id: i32,
|
||||
db: DatabaseConnection<Database>,
|
||||
) -> (Status, Json<DeleteReceiptResponse>) {
|
||||
let mut repo = ReceiptsRepository::new(db);
|
||||
match repo.delete_receipt(id).await {
|
||||
Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Json(DeleteReceiptResponse::Error(e.to_string())),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
rocket::routes![
|
||||
add_receipt,
|
||||
delete_receipt,
|
||||
get_receipt,
|
||||
list_receipts,
|
||||
receipt_form,
|
||||
view_receipt_photo,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::State;
|
||||
use tracing::error;
|
||||
|
||||
use crate::transactions::*;
|
||||
use crate::{Context, ErrorResponse};
|
||||
|
||||
#[rocket::get("/", format = "json")]
|
||||
async fn transaction_list(
|
||||
ctx: &State<Context>,
|
||||
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
|
||||
let result = ctx
|
||||
.firefly
|
||||
.search_transactions(&ctx.config.firefly.search_query)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error fetching transaction list: {}", e);
|
||||
ErrorResponse::new(
|
||||
"Failed to fetch transaction list from Firefly III",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(
|
||||
result
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|t| match Transaction::try_from(t) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
error!("Error parsing transaction details: {}", e);
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
rocket::routes![transaction_list]
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
use chrono::{DateTime, FixedOffset};
|
||||
use serde::Serialize;
|
||||
use tracing::error;
|
||||
|
||||
use crate::firefly::TransactionRead;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Transaction {
|
||||
pub id: String,
|
||||
pub amount: f64,
|
||||
pub description: String,
|
||||
pub date: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl TryFrom<TransactionRead> for Transaction {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> {
|
||||
let first_split = match t.attributes.transactions.first() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("Invalid transaction {}: no splits", t.id);
|
||||
return Err("Transaction has no splits");
|
||||
},
|
||||
};
|
||||
let date = first_split.date;
|
||||
let amount = t.amount();
|
||||
let description = if let Some(title) = &t.attributes.group_title {
|
||||
title.into()
|
||||
} else {
|
||||
first_split.description.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
id: t.id,
|
||||
amount,
|
||||
description,
|
||||
date,
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue