From 572b7e60efa032356dd2592624a7c6760a43467d Mon Sep 17 00:00:00 2001 From: manglemix Date: Tue, 14 Jan 2025 23:01:55 -0700 Subject: [PATCH] more verbose status logging --- Makefile.toml | 12 + usr-backend/src/main.rs | 49 +++- usr-backend/src/manifest.rs | 232 +++++++++++------- usr-backend/src/manifest/order.rs | 29 +-- usr-backend/src/manifest/order_status.rs | 42 ++++ .../src/routes/(apps)/manifest/+page.svelte | 92 +++++-- 6 files changed, 311 insertions(+), 145 deletions(-) create mode 100644 Makefile.toml create mode 100644 usr-backend/src/manifest/order_status.rs diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..50d0556 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,12 @@ +[tasks.web] +workspace = false +cwd = "usr-web" +script_runner = "@shell" +script = ''' +npm run dev -- -- --host +''' + +[tasks.backend] +workspace = false +command = "cargo" +args = ["run", "-p", "usr-backend"] diff --git a/usr-backend/src/main.rs b/usr-backend/src/main.rs index 7fdfdea..915f3b9 100644 --- a/usr-backend/src/main.rs +++ b/usr-backend/src/main.rs @@ -36,14 +36,14 @@ impl Write for LogWriter { #[derive(Deserialize)] struct Config { - new_orders_webhook: String, - order_updates_webhook: String, + new_orders_webhook: Option, + order_updates_webhook: Option, } struct UsrState { db: DatabaseConnection, - new_orders_webhook: DiscordWebhook, - order_updates_webhook: DiscordWebhook, + new_orders_webhook: Option, + order_updates_webhook: Option, } #[tokio::main] @@ -84,10 +84,29 @@ async fn main() -> anyhow::Result<()> { if Path::new(".reset-db").exists() { info!("Resetting DB"); + let directive = std::fs::read_to_string(".reset-db")?; + + match directive.as_str() { + "scheduler" => { + scheduler::reset_tables(&db).await?; + info!("Reset scheduler tables"); + } + "manifest" => { + manifest::reset_tables(&db).await?; + info!("Reset manifest tables"); + } + "all" => { + scheduler::reset_tables(&db).await?; + manifest::reset_tables(&db).await?; + info!("Reset all tables"); + } + _ => { + error!("Invalid directive in .reset-db"); + return Ok(()); + } + } + std::fs::remove_file(".reset-db")?; - scheduler::reset_tables(&db).await?; - manifest::reset_tables(&db).await?; - info!("DB Reset"); } let app = Router::new() @@ -123,8 +142,20 @@ async fn main() -> anyhow::Result<()> { ) .with_state(Arc::new(UsrState { db, - new_orders_webhook: DiscordWebhook::new(config.new_orders_webhook)?, - order_updates_webhook: DiscordWebhook::new(config.order_updates_webhook)?, + new_orders_webhook: { + if let Some(new_orders_webhook) = config.new_orders_webhook { + Some(DiscordWebhook::new(new_orders_webhook)?) + } else { + None + } + }, + order_updates_webhook: { + if let Some(order_updates_webhook) = config.order_updates_webhook { + Some(DiscordWebhook::new(order_updates_webhook)?) + } else { + None + } + }, })); default_provider() diff --git a/usr-backend/src/manifest.rs b/usr-backend/src/manifest.rs index ac43b96..d5f4a74 100644 --- a/usr-backend/src/manifest.rs +++ b/usr-backend/src/manifest.rs @@ -7,7 +7,7 @@ use axum::{ use discord_webhook2::message::Message; use parking_lot::Mutex; use sea_orm::{ - prelude::Decimal, sea_query::Table, sqlx::types::chrono::Local, ActiveModelTrait, ActiveValue, ConnectionTrait, DatabaseConnection, EntityTrait, Schema + prelude::Decimal, sea_query::Table, sqlx::types::chrono::Local, ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Schema, TransactionTrait }; use serde::Deserialize; use tracing::error; @@ -15,6 +15,8 @@ use tracing::error; use crate::{scheduler, UsrState}; mod order; +mod order_status; + struct BatchedTask { queue: HashMap, deadline: Option, @@ -55,8 +57,6 @@ async fn new_order( let active_model = order::ActiveModel { id: ActiveValue::NotSet, name: ActiveValue::Set(pending_order.name), - date: ActiveValue::Set(Local::now().naive_local()), - status: ActiveValue::Set(order::Status::New), count: ActiveValue::Set(pending_order.count), unit_cost: ActiveValue::Set(pending_order.unit_cost), store_in: ActiveValue::Set(pending_order.store_in), @@ -65,7 +65,22 @@ async fn new_order( vendor: ActiveValue::Set(pending_order.vendor), link: ActiveValue::Set(pending_order.link), }; - match active_model.insert(&state.db).await { + let result = state.db.transaction(|tx| Box::pin(async move { + let model = active_model.insert(tx).await?; + + let active_model = order_status::ActiveModel { + order_id: ActiveValue::Set(model.id), + instance_id: ActiveValue::NotSet, + date: ActiveValue::Set(Local::now().naive_local()), + status: ActiveValue::Set(order_status::Status::New), + }; + + active_model.insert(tx).await?; + + Result::<_, sea_orm::DbErr>::Ok(model) + })).await; + + match result { Ok(m) => { let mut guard = BATCHED.lock(); guard.queue.insert(m.id, webhook_msg); @@ -74,50 +89,50 @@ async fn new_order( if was_none { drop(guard); - - tokio::spawn(async move { - loop { - let deadline = BATCHED.lock().deadline.unwrap(); - tokio::time::sleep_until(deadline.into()).await; - let queue; - { - let mut guard = BATCHED.lock(); - if guard.deadline.unwrap() != deadline { - continue; + if state.new_orders_webhook.is_some() { + tokio::spawn(async move { + let new_orders_webhook = state.new_orders_webhook.as_ref().unwrap(); + loop { + let deadline = BATCHED.lock().deadline.unwrap(); + tokio::time::sleep_until(deadline.into()).await; + let queue; + { + let mut guard = BATCHED.lock(); + if guard.deadline.unwrap() != deadline { + continue; + } + let replacement = HashMap::with_capacity(guard.queue.capacity()); + queue = std::mem::replace(&mut guard.queue, replacement); } - let replacement = HashMap::with_capacity(guard.queue.capacity()); - queue = std::mem::replace(&mut guard.queue, replacement); - } - let mut running = String::new(); - for (_, msg) in queue { - if running.len() + msg.len() + 1 < 2000 { - running.push_str(&msg); - running.push_str("\n"); - } else { - if let Err(e) = state - .new_orders_webhook - .send(&Message::new(|message| message.content(running))) - .await - { - error!("Failed to trigger new-order webhook: {e}"); + let mut running = String::new(); + for (_, msg) in queue { + if running.len() + msg.len() + 1 < 2000 { + running.push_str(&msg); + running.push_str("\n"); + } else { + if let Err(e) = new_orders_webhook + .send(&Message::new(|message| message.content(running))) + .await + { + error!("Failed to trigger new-order webhook: {e}"); + } + running = msg; } - running = msg; + } + if let Err(e) = new_orders_webhook + .send(&Message::new(|message| message.content(running))) + .await + { + error!("Failed to trigger new-order webhook: {e}"); + } + let mut guard = BATCHED.lock(); + if guard.queue.is_empty() { + guard.deadline = None; + break; } } - if let Err(e) = state - .new_orders_webhook - .send(&Message::new(|message| message.content(running))) - .await - { - error!("Failed to trigger new-order webhook: {e}"); - } - let mut guard = BATCHED.lock(); - if guard.queue.is_empty() { - guard.deadline = None; - break; - } - } - }); + }); + } } (StatusCode::OK, "") @@ -147,9 +162,9 @@ async fn change_order( State(state): State>, Json(change_order): Json, ) -> (StatusCode, &'static str) { - match order::Entity::find_by_id(change_order.id).one(&state.db).await { + match order_status::Entity::find().filter(order_status::Column::OrderId.eq(change_order.id)).order_by_desc(order_status::Column::InstanceId).one(&state.db).await { Ok(Some(model)) => { - if model.status != order::Status::New { + if model.status != order_status::Status::New { return (StatusCode::BAD_REQUEST, "Order has already been processed"); } } @@ -175,8 +190,6 @@ async fn change_order( let active_model = order::ActiveModel { id: ActiveValue::Unchanged(change_order.id), name: ActiveValue::Set(change_order.name), - date: ActiveValue::NotSet, - status: ActiveValue::NotSet, count: ActiveValue::Set(change_order.count), unit_cost: ActiveValue::Set(change_order.unit_cost), store_in: ActiveValue::Set(change_order.store_in), @@ -196,8 +209,8 @@ async fn change_order( } Entry::Vacant(_) => { tokio::spawn(async move { - if let Err(e) = state - .new_orders_webhook + let Some(new_orders_webhook) = state.new_orders_webhook.as_ref() else { return; }; + if let Err(e) = new_orders_webhook .send(&Message::new(|message| message.content(webhook_msg))) .await { @@ -213,20 +226,31 @@ async fn change_order( #[derive(Deserialize)] struct DeleteOrder { - id: u32 + id: u32, + #[serde(default)] + force: bool } #[axum::debug_handler] async fn cancel_order( State(state): State>, - Json(DeleteOrder { id }): Json, + Json(DeleteOrder { id, force }): Json, ) -> (StatusCode, &'static str) { let webhook_msg; - match order::Entity::find_by_id(id).one(&state.db).await { + + match order_status::Entity::find().filter(order_status::Column::OrderId.eq(id)).order_by_desc(order_status::Column::InstanceId).one(&state.db).await { Ok(Some(model)) => { - if model.status != order::Status::New { + if !force && model.status != order_status::Status::New { return (StatusCode::BAD_REQUEST, "Order has already been processed"); } + let model = match order::Entity::find_by_id(id).one(&state.db).await { + Ok(Some(model)) => model, + Ok(None) => unreachable!(), + Err(e) => { + error!("Failed to find order: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, ""); + } + }; webhook_msg = format!( ">>> ***Order Cancelled***\n**Name:** {}\n**Count:** {}\n**Team:** {}", model.name, @@ -243,27 +267,40 @@ async fn cancel_order( } } - if let Err(e) = order::Entity::delete_by_id(id).exec(&state.db).await { + if force { + let result = state.db.transaction(|tx| Box::pin(async move { + order::Entity::delete_by_id(id).exec(tx).await?; + order_status::Entity::delete_many().filter(order_status::Column::OrderId.eq(id)).exec(tx).await?; + Result::<_, sea_orm::DbErr>::Ok(()) + })).await; + + if let Err(e) = result { + error!("Failed to force delete order: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, ""); + } + + } else if let Err(e) = order::Entity::delete_by_id(id).exec(&state.db).await { error!("Failed to delete order: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, "") - } else { - tokio::spawn(async move { - if let Err(e) = state - .new_orders_webhook - .send(&Message::new(|message| message.content(webhook_msg))) - .await - { - error!("Failed to trigger new-order webhook: {e}"); - } - }); - (StatusCode::OK, "") + return (StatusCode::INTERNAL_SERVER_ERROR, ""); } + + tokio::spawn(async move { + let Some(new_orders_webhook) = state.new_orders_webhook.as_ref() else { return; }; + if let Err(e) = new_orders_webhook + .send(&Message::new(|message| message.content(webhook_msg))) + .await + { + error!("Failed to trigger new-order webhook: {e}"); + } + }); + + (StatusCode::OK, "") } #[derive(Deserialize)] pub struct UpdateOrder { pub id: u32, - pub status: order::Status + pub status: order_status::Status } #[axum::debug_handler] @@ -272,15 +309,24 @@ async fn update_order( Json(update_order): Json, ) -> (StatusCode, &'static str) { let webhook_msg; - match order::Entity::find_by_id(update_order.id).one(&state.db).await { + + match order_status::Entity::find().filter(order_status::Column::OrderId.eq(update_order.id)).order_by_desc(order_status::Column::InstanceId).one(&state.db).await { Ok(Some(model)) => { - if model.status == order::Status::InStorage { + if model.status == order_status::Status::InStorage { return (StatusCode::BAD_REQUEST, "Order is already in storage"); } if model.status == update_order.status { return (StatusCode::BAD_REQUEST, "Order is already in that state"); } - if update_order.status == order::Status::InStorage { + let model = match order::Entity::find_by_id(update_order.id).one(&state.db).await { + Ok(Some(model)) => model, + Ok(None) => unreachable!(), + Err(e) => { + error!("Failed to find order: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, ""); + } + }; + if update_order.status == order_status::Status::InStorage { if model.store_in.is_empty() { webhook_msg = format!( ">>> **Order Complete!**\n**Name:** {}\n**Team:** {}", @@ -313,26 +359,19 @@ async fn update_order( } } - let active_model = order::ActiveModel { - id: ActiveValue::Unchanged(update_order.id), - name: ActiveValue::NotSet, - date: ActiveValue::NotSet, + let active_model = order_status::ActiveModel { + order_id: ActiveValue::Set(update_order.id), + instance_id: ActiveValue::NotSet, + date: ActiveValue::Set(Local::now().naive_local()), status: ActiveValue::Set(update_order.status), - count: ActiveValue::NotSet, - unit_cost: ActiveValue::NotSet, - store_in: ActiveValue::NotSet, - team: ActiveValue::NotSet, - reason: ActiveValue::NotSet, - vendor: ActiveValue::NotSet, - link: ActiveValue::NotSet, }; - if let Err(e) = active_model.update(&state.db).await { - error!("Failed to update order: {e}"); + if let Err(e) = active_model.insert(&state.db).await { + error!("Failed to update order status: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "") } else { tokio::spawn(async move { - if let Err(e) = state - .order_updates_webhook + let Some(order_updates_webhook) = state.order_updates_webhook.as_ref() else { return; }; + if let Err(e) = order_updates_webhook .send(&Message::new(|message| message.content(webhook_msg))) .await { @@ -350,8 +389,21 @@ async fn get_orders( let result = order::Entity::find().all(&state.db).await; match result { - Ok(models) => { - Json(models).into_response() + Ok(orders) => { + let result = order_status::Entity::find().all(&state.db).await; + + match result { + Ok(statuses) => { + Json(serde_json::json!({ + "orders": orders, + "statuses": statuses + })).into_response() + } + Err(e) => { + error!("Failed to get orders: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "").into_response() + } + } } Err(e) => { error!("Failed to get orders: {e}"); @@ -377,6 +429,10 @@ pub async fn reset_tables(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> .await?; db.execute(builder.build(&schema.create_table_from_entity(order::Entity))) .await?; + db.execute(builder.build(Table::drop().table(order_status::Entity).if_exists())) + .await?; + db.execute(builder.build(&schema.create_table_from_entity(order_status::Entity))) + .await?; Ok(()) } diff --git a/usr-backend/src/manifest/order.rs b/usr-backend/src/manifest/order.rs index 663bcbd..66c21a5 100644 --- a/usr-backend/src/manifest/order.rs +++ b/usr-backend/src/manifest/order.rs @@ -1,7 +1,5 @@ -use std::fmt::Display; - use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::scheduler; @@ -11,8 +9,6 @@ pub struct Model { #[sea_orm(primary_key)] pub id: u32, pub name: String, - pub date: DateTime, - pub status: Status, pub count: u32, pub unit_cost: Decimal, pub store_in: String, @@ -26,25 +22,4 @@ pub struct Model { pub enum Relation { } -impl ActiveModelBehavior for ActiveModel {} - -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Hash, Copy, Serialize)] -#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(1))")] -pub enum Status { - #[sea_orm(string_value = "N")] - New, - #[sea_orm(string_value = "S")] - Submitted, - #[sea_orm(string_value = "F")] - Shipped, - #[sea_orm(string_value = "D")] - Delivered, - #[sea_orm(string_value = "I")] - InStorage, -} - -impl Display for Status { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -} \ No newline at end of file +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/usr-backend/src/manifest/order_status.rs b/usr-backend/src/manifest/order_status.rs new file mode 100644 index 0000000..4a35380 --- /dev/null +++ b/usr-backend/src/manifest/order_status.rs @@ -0,0 +1,42 @@ +use std::fmt::Display; + +use sea_orm::prelude::*; +use serde::{Deserialize, Serialize}; + + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize)] +#[sea_orm(table_name = "order_status")] +pub struct Model { + #[sea_orm(primary_key)] + pub instance_id: u32, + pub order_id: u32, + pub date: DateTime, + pub status: Status +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Hash, Copy, Serialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(1))")] +pub enum Status { + #[sea_orm(string_value = "N")] + New, + #[sea_orm(string_value = "S")] + Submitted, + #[sea_orm(string_value = "F")] + Shipped, + #[sea_orm(string_value = "D")] + Delivered, + #[sea_orm(string_value = "I")] + InStorage, +} + +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} \ No newline at end of file diff --git a/usr-web/src/routes/(apps)/manifest/+page.svelte b/usr-web/src/routes/(apps)/manifest/+page.svelte index a5ff066..c21eb61 100644 --- a/usr-web/src/routes/(apps)/manifest/+page.svelte +++ b/usr-web/src/routes/(apps)/manifest/+page.svelte @@ -12,17 +12,13 @@ $effect(() => { if (hideInStorage) { - if (selectedOrderId !== null && orders[selectedOrderId].status === 'In Storage') { - selectedOrderId = null; - } + selectedOrderId = null; } }); interface Order { id: number; name: string; - date: string; - status: 'New' | 'Submitted' | 'Shipped' | 'Delivered' | 'InStorage' | 'In Storage'; count: number; unit_cost: number | string; store_in: string; @@ -33,23 +29,36 @@ } let orders: Order[] = $state([]); + interface OrderStatus { + order_id: number; + instance_id: number; + date: string; + status: 'New' | 'Submitted' | 'Shipped' | 'Delivered' | 'InStorage' | 'In Storage'; + } + let statuses: OrderStatus[] = $state([]); + async function refreshOrders() { fetching = true; const response = await fetch(`${PUBLIC_API_ENDPOINT}/api/manifest/list/order`); selectedOrderId = null; - orders = await response.json(); + const body = await response.json(); + orders = body.orders; orders = orders.map((order) => { - order.date = new Date(order.date).toLocaleString('en-US', { + order.unit_cost = parseFloat(order.unit_cost as string); + return order; + }); + statuses = body.statuses; + statuses = statuses.map((status) => { + status.date = new Date(status.date).toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' }); - if (order.status === 'InStorage') { - order.status = 'In Storage'; + if (status.status === 'InStorage') { + status.status = 'In Storage'; } - order.unit_cost = parseFloat(order.unit_cost as string); - return order; + return status; }); setTimeout(() => { fetching = false; @@ -68,7 +77,7 @@ let pending_order_store_in: string = $state(''); let pending_order_team: Team | '' = $state(''); let pending_order_reason = $state(''); - let updated_order_status: Order['status'] | '' = $state(''); + let updated_order_status: OrderStatus['status'] | '' = $state(''); function populatePending() { if (selectedOrderId !== null) { @@ -95,6 +104,12 @@ pending_order_reason = ''; updated_order_status = ''; } + + function statusesOf(orderId: number) { + const out = statuses.filter((status) => status.order_id === orderId); + out.sort((a, b) => (a.instance_id < b.instance_id ? -1 : 1)); + return out; + } @@ -118,10 +133,9 @@ Name - Date + Status Vendor Link - Status Count Unit Cost Store In @@ -132,7 +146,7 @@ {#each orders as order, i} - {#if !hideInStorage || order.status !== 'In Storage'} + {#if !hideInStorage || statusesOf(order.id).pop()?.status !== 'In Storage'} { selectedOrderId = order.id; @@ -144,10 +158,13 @@ id={selectedOrderId === order.id ? 'selectedOrder' : ''} > {order.name} - {order.date} + + {#each statusesOf(order.id) as status} +

{status.status}: {status.date}

+ {/each} + {order.vendor} Link - {order.status} {order.count} {order.unit_cost.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} Cancel Order + {#snippet selectAnOrder()}

Select an order

@@ -432,6 +458,33 @@ {orderOperationOutput} {/if} + {:else if tabIndex === 4} + {#if selectedOrderId === null} + {@render selectAnOrder()} + {:else} + + {orderOperationOutput} + {/if} {/if} @@ -461,11 +514,8 @@ .order-name { min-width: 5rem; } - .order-date { - min-width: 11rem; - } .order-status { - min-width: 6rem; + min-width: 16rem; } #order-tabs > button { background-color: darkgray;