From ba0ac20dec0677681f25c5fbdd6c05b3b9b46fe7 Mon Sep 17 00:00:00 2001 From: Fay Carsons Date: Tue, 4 Jun 2024 18:54:41 -0400 Subject: [PATCH] add metric gathering --- backend/Cargo.toml | 1 + backend/src/api/metrics.rs | 78 +++++++++ backend/src/api/mod.rs | 1 + backend/src/api/stock.rs | 42 +++-- backend/src/api/stripe.rs | 31 ++-- backend/src/mail/send.rs | 2 +- backend/src/mail/templates.rs | 39 +++-- backend/src/main.rs | 6 +- backend/src/tests/api.rs | 4 +- backend/src/tests/db.rs | 2 +- backend/src/tests/mail.rs | 8 +- model/Cargo.toml | 2 + .../2024-06-02-231054_metrics/down.sql | 1 + .../2024-06-02-231054_metrics/up.sql | 10 ++ model/src/lib.rs | 1 + model/src/schema.rs | 14 ++ model/src/user.rs | 151 ++++++++++++++++++ 17 files changed, 326 insertions(+), 67 deletions(-) create mode 100644 backend/src/api/metrics.rs create mode 100644 model/migrations/2024-06-02-231054_metrics/down.sql create mode 100644 model/migrations/2024-06-02-231054_metrics/up.sql create mode 100644 model/src/user.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ba9e005..104fbb6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -36,6 +36,7 @@ lettre = { version = "0.11.7", default-features = false, features = [ "smtp-transport", ] } lettre_email = "0.9.4" +chrono = { version = "0.4.38", features = ["serde"] } [profile.test] debug-assertions = false diff --git a/backend/src/api/metrics.rs b/backend/src/api/metrics.rs new file mode 100644 index 0000000..02d8087 --- /dev/null +++ b/backend/src/api/metrics.rs @@ -0,0 +1,78 @@ +// NOTE: The functions in this module ignore `Err` values because failing to log a visit is ok +use actix_web::{web, HttpRequest, Result}; +use awc::Client; +use chrono::prelude::*; +use diesel::RunQueryDsl; +use model::{ + schema::users, + user::{Device, Location, NewUser, User}, +}; +use std::sync::Arc; + +use crate::{DbConn, DbPool}; + +async fn get_location(ip: &str) -> Result { + actix_web::rt::System::new().block_on(async { + let client = Client::default(); + client + .get(format!("http://ip-api.com/json/{ip}")) + .send() + .await + .map_err(|_| ())? + .json::() + .await + .map_err(|_| ()) + }) +} + +async fn get_user(req: HttpRequest) -> Result { + let time = Utc::now().naive_utc(); + let ip = req.peer_addr().map(|addr| addr.ip().to_string()); + + let location = if let Some(ref ip) = ip { + get_location(ip).await.unwrap_or_default() + } else { + Location::default() + }; + + let ip = ip.unwrap_or_default(); + + let user_agent = req + .headers() + .get("User-Agent") + .and_then(|field| field.to_str().ok()) + .map(|s| s.to_owned()); + + let device = user_agent.as_ref().map(Device::from).unwrap_or_default(); + + let Location { + country, + state, + city, + } = location; + + Ok(User { + device, + ip, + user_agent, + time, + country, + state, + city, + }) +} + +async fn insert_user(user: User, mut conn: DbConn) { + let _ = web::block(move || { + diesel::insert_into(users::table) + .values(NewUser::from(&user)) + .execute(&mut conn) + }) + .await; +} + +pub async fn log_user(req: HttpRequest, conn: DbConn) { + if let Ok(user) = get_user(req).await { + insert_user(user, conn).await + } // Otherwise do nothing! missing a visit is fine :) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 89c285c..f952e17 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,3 +1,4 @@ +mod metrics; pub mod order; pub mod stock; pub mod stripe; diff --git a/backend/src/api/stock.rs b/backend/src/api/stock.rs index 51b8f2d..7277bef 100644 --- a/backend/src/api/stock.rs +++ b/backend/src/api/stock.rs @@ -1,6 +1,6 @@ use model::{ - item::{self, Item, NewItem, NewQuantity, TableItem}, - schema::{self, stock}, + item::{self, Item, NewItem, TableItem}, + schema::stock, CartMap, ItemId, }; @@ -14,6 +14,7 @@ use std::{collections::HashMap, sync::Arc}; use diesel::{prelude::*, r2d2::ConnectionManager}; +use super::metrics::log_user; use crate::DbPool; use super::stripe::StripeItem; @@ -54,7 +55,7 @@ pub async fn get_total(cart: Arc, pool: Arc) -> Result { .map_err(|e| error::ErrorInternalServerError(format!("\n{e}")))?; let id_kind_map = HashMap::::from_iter(id_kind_pairs.into_iter()); - Ok(cart.iter().try_fold(0, |total, (id, qty)| { + cart.iter().try_fold(0, |total, (id, qty)| { if let Some(kind) = id_kind_map.get(&(*id as i32)) { let price = match item::Kind::from(*kind) { item::Kind::BigPrint => 20_00, @@ -67,7 +68,7 @@ pub async fn get_total(cart: Arc, pool: Arc) -> Result { "\nError fetching total - Item in cart not present in kind map", )) } - })?) + }) } #[get("/stock/{item_id}")] @@ -79,16 +80,23 @@ pub async fn get_item(item_id: Path, pool: web::Data) -> Result) -> Result { - let stock = web::block(move || { - let mut conn = pool.get().map_err(|_| "couldn't get db connection")?; - stock::table - .select(TableItem::as_select()) - .get_results::(&mut conn) - .map_err(|e| format!("Cannot fetch stock: {e}")) - }) - .await? - .map_err(error::ErrorInternalServerError)?; +pub async fn get_stock( + req: actix_web::HttpRequest, + pool: web::Data, +) -> Result { + let stock = { + let mut stock_conn = pool + .get() + .map_err(|e| error::ErrorInternalServerError(format!("Cannot connect to DB: {e}")))?; + web::block(move || { + stock::table + .select(TableItem::as_select()) + .get_results::(&mut stock_conn) + .map_err(|e| format!("Cannot fetch stock: {e}")) + }) + .await? + .map_err(error::ErrorInternalServerError)? + }; let stock = stock .into_iter() @@ -96,6 +104,10 @@ pub async fn get_stock(pool: web::Data) -> Result { .collect::>(); let ser = to_string(&stock)?; + if let Ok(metrics_conn) = pool.get() { + actix_web::rt::spawn(log_user(req, metrics_conn)); + } + Ok(HttpResponse::Ok() .content_type("application/json") .body(ser)) @@ -229,7 +241,7 @@ pub async fn get_title_map(cart: Arc, pool: DbPool) -> Result, pool: Arc) -> Result> { diff --git a/backend/src/api/stripe.rs b/backend/src/api/stripe.rs index 837b76d..ef4f688 100644 --- a/backend/src/api/stripe.rs +++ b/backend/src/api/stripe.rs @@ -1,6 +1,5 @@ -use lettre::{AsyncSmtpTransport, Tokio1Executor}; use serde::{Deserialize, Serialize}; -use std::{borrow::Borrow, collections::HashMap, num::ParseIntError, sync::Arc}; +use std::{borrow::Borrow, collections::HashMap, sync::Arc}; use actix_web::{ error, post, rt, @@ -21,20 +20,13 @@ use stripe::{ }; use crate::{ - api::{ - order::insert_order, - stock::{dec_items, get_title_map, get_total, item_from_db}, - }, + api::{order::insert_order, stock::dec_items}, mail, utils::print_red, DbPool, Env, Mailer, }; -use model::{ - address::Address, - item::{Item, TableItem}, - CartMap, ItemId, Quantity, -}; +use model::{address::Address, ItemId, Quantity}; use super::stock::get_matching_ids; @@ -80,16 +72,13 @@ pub async fn checkout( let mut product_price_pairs = Vec::<(Price, u64)>::with_capacity(item_map.keys().len()); - for ( - _, - StripeItem { - title, - price, - quantity, - }, - ) in &item_map + for StripeItem { + title, + price, + quantity, + } in item_map.values() { - let create_product = CreateProduct::new(&title); + let create_product = CreateProduct::new(title); let product = Product::create(&client, create_product) .await .map_err(|e| error::ErrorInternalServerError(e.to_string()))?; @@ -217,7 +206,7 @@ pub async fn parse_webhook( if let Ok(event) = event { if let EventType::CheckoutSessionCompleted = event.type_ { if let EventObject::CheckoutSession(session) = event.data.object { - handle_checkout(session, pool, &*env, mailer.into_inner()).await?; + handle_checkout(session, pool, &env, mailer.into_inner()).await?; } } } else { diff --git a/backend/src/mail/send.rs b/backend/src/mail/send.rs index 67e957f..69468d6 100644 --- a/backend/src/mail/send.rs +++ b/backend/src/mail/send.rs @@ -16,7 +16,7 @@ pub async fn send_confirmation(user: UserData, mailer: Arc) -> Result<() let email = Message::builder() .from("Kiggyshop ".parse().unwrap()) - .to("Kiggy ".parse().unwrap()) + .to("Fay ".parse().unwrap()) .subject("Thank you for your order!") .multipart( MultiPart::alternative() diff --git a/backend/src/mail/templates.rs b/backend/src/mail/templates.rs index 916a5fd..ce27b6c 100644 --- a/backend/src/mail/templates.rs +++ b/backend/src/mail/templates.rs @@ -5,7 +5,7 @@ use crate::api::stripe::{StripeItem, UserData}; pub struct Item { title: String, - price: f32, + price: f64, quantity: u32, total: u32, } @@ -15,7 +15,7 @@ impl From<(&model::item::Item, &Quantity)> for Item { let price = item.price(); Self { title: item.title.clone(), - price: price as f32 / 1000., + price: price as f64 / 100f64, quantity: *quantity, total: price * quantity, } @@ -27,7 +27,7 @@ impl From<(&model::item::Item, &Quantity)> for Item { pub struct Confirmation { name: String, address: String, - total: f32, + total: f64, cart: Vec, } @@ -35,13 +35,18 @@ impl Confirmation { pub fn render_plaintext(&self) -> String { let Confirmation { name, - address, - total, + total: order_total, cart, + .. } = self; + let title = "Title"; + let price = "Price"; + let quantity = "Quantity"; + let total = "Total"; + let [title_width, price_width, quantity_width, total_width] = cart.iter().fold( - [5, 5, 8, 5], + [title.len(), price.len(), quantity.len(), total.len()], |mut acc, Item { title, @@ -90,7 +95,7 @@ impl Confirmation { let order_details = cart .iter() - .fold(format!("{separator}\n{header}"), + .fold(format!("{separator}\n{header}\n"), |mut acc, Item { title, price, @@ -106,20 +111,14 @@ impl Confirmation { let total_box = format!( "{separator}\n| {:>total_width$} |\n{separator}", - String::from("Total: ") + &total.to_string(), - total_width = title_width + price_width + quantity_width + total_width + String::from("Total: ") + &order_total.to_string(), + total_width = [title_width, price_width, quantity_width, total_width] + .into_iter() + .sum() ); format!( - r#" - Thank you {name}! - - We appreciate your support! Your order is currently being processed, a - shipping confirmation will be sent shortly. - - {order_details} - {total_box} - "# + "Thank you {name}!\n\nWe appreciate your support! Your order is currently being processed, a shipping confirmation will be sent shortly.\n\n{order_details}\n{total_box}" ) } } @@ -146,7 +145,7 @@ impl From<&UserData> for Confirmation { }, )| Item { title: title.clone(), - price: (*price as f32) / 100., + price: (*price as f64) / 100f64, quantity: *quantity, total: *total, }, @@ -161,7 +160,7 @@ impl From<&UserData> for Confirmation { Confirmation { name: name.clone(), address, - total: (*total as f32) / 100., + total: (*total as f64) / 100f64, cart, } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 7d05b9f..fcb69c0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,10 +15,14 @@ use env::Env; use actix_web::{middleware::Logger, web, App, HttpServer}; -use diesel::{r2d2, SqliteConnection}; +use diesel::{ + r2d2::{self, ConnectionManager}, + SqliteConnection, +}; use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor}; pub type DbPool = r2d2::Pool>; +pub type DbConn = r2d2::PooledConnection>; pub type Mailer = AsyncSmtpTransport; const ADDRESS_PORT: (&str, u16) = ("0.0.0.0", 3000); diff --git a/backend/src/tests/api.rs b/backend/src/tests/api.rs index 1afdc7b..e09c6df 100644 --- a/backend/src/tests/api.rs +++ b/backend/src/tests/api.rs @@ -5,7 +5,7 @@ mod tests { use crate::{ api::{ - order::{self, delete_order}, + order::{delete_order}, stock::get_stock, }, tests::test_db, @@ -13,7 +13,7 @@ mod tests { use actix_web::{test, web, App}; use diesel::SqliteConnection; use model::{ - item::{Item, NewItem, TableItem}, + item::{Item, NewItem}, order::{NewOrder, Order}, ItemId, }; diff --git a/backend/src/tests/db.rs b/backend/src/tests/db.rs index f29e356..b338cd6 100644 --- a/backend/src/tests/db.rs +++ b/backend/src/tests/db.rs @@ -7,7 +7,7 @@ mod tests { dsl::count, query_dsl::methods::SelectDsl, ExpressionMethods, QueryDsl, RunQueryDsl, }; use model::{ - cart::{Cart, NewCart}, + cart::NewCart, item::{Item, NewItem}, order::{NewOrder, Order}, }; diff --git a/backend/src/tests/mail.rs b/backend/src/tests/mail.rs index 09a22da..7ffb161 100644 --- a/backend/src/tests/mail.rs +++ b/backend/src/tests/mail.rs @@ -1,16 +1,12 @@ use std::sync::Arc; -use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor}; +use lettre::transport::smtp::authentication::Credentials; use model::order::Order; use crate::{ - api::{ - stock::item_from_db, - stripe::{StripeItem, UserData}, - }, + api::stripe::{StripeItem, UserData}, mail::{ self, - templates::{Confirmation, Item}, }, Mailer, }; diff --git a/model/Cargo.toml b/model/Cargo.toml index 8c5dc02..5ac7957 100644 --- a/model/Cargo.toml +++ b/model/Cargo.toml @@ -6,10 +6,12 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = { version = "0.4.38", features = ["serde"] } diesel = { version = "2.1.3", features = [ "sqlite", "returning_clauses_for_sqlite_3_35", "r2d2", + "chrono", ] } serde = { version = "1.0.197", features = ["derive"] } uuid = { version = "1.8.0", features = ["v4", "serde"] } diff --git a/model/migrations/2024-06-02-231054_metrics/down.sql b/model/migrations/2024-06-02-231054_metrics/down.sql new file mode 100644 index 0000000..7fa478c --- /dev/null +++ b/model/migrations/2024-06-02-231054_metrics/down.sql @@ -0,0 +1 @@ +drop table users; diff --git a/model/migrations/2024-06-02-231054_metrics/up.sql b/model/migrations/2024-06-02-231054_metrics/up.sql new file mode 100644 index 0000000..6bee6f9 --- /dev/null +++ b/model/migrations/2024-06-02-231054_metrics/up.sql @@ -0,0 +1,10 @@ +create table users ( + id integer primary key autoincrement, + ip text not null, + user_agent text, + device text not null, + time datetime default CURRENT_TIMESTAMP, + country text not null, + state text not null, + city text +); diff --git a/model/src/lib.rs b/model/src/lib.rs index c921e88..1e8dfd3 100644 --- a/model/src/lib.rs +++ b/model/src/lib.rs @@ -3,6 +3,7 @@ pub mod cart; pub mod item; pub mod order; pub mod schema; +pub mod user; // Types follow a pattern of: // {Name} -> struct for business logic, uses ideal types and is therefore safer diff --git a/model/src/schema.rs b/model/src/schema.rs index 93c69de..f0a3dbd 100644 --- a/model/src/schema.rs +++ b/model/src/schema.rs @@ -42,6 +42,19 @@ diesel::table! { } } +diesel::table! { + users (id) { + id -> Nullable, + ip -> Text, + user_agent -> Nullable, + device -> Text, + time -> Nullable, + country -> Text, + state -> Text, + city -> Nullable, + } +} + diesel::joinable!(addresses -> orders (order_id)); diesel::joinable!(carts -> orders (order_id)); diesel::joinable!(carts -> stock (item_id)); @@ -51,4 +64,5 @@ diesel::allow_tables_to_appear_in_same_query!( carts, orders, stock, + users, ); diff --git a/model/src/user.rs b/model/src/user.rs new file mode 100644 index 0000000..4154ad4 --- /dev/null +++ b/model/src/user.rs @@ -0,0 +1,151 @@ +use chrono::NaiveDateTime; +use diesel::{Insertable, Selectable}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize)] +#[repr(u8)] +pub enum Device { + Linux, + Mac, + Windows, + Iphone, + Android, + #[default] + Unknown, +} + +impl From for Device +where + T: AsRef, +{ + fn from(value: T) -> Self { + use Device::*; + let value = value.as_ref().to_lowercase(); + if value.contains("linux") { + if value.contains("android") { + Android + } else { + Linux + } + } else if value.contains("macintosh") || value.contains("mac os x") { + Mac + } else if value.contains("windows") { + Windows + } else if value.contains("iphone") { + Iphone + } else { + Unknown + } + } +} + +impl std::fmt::Display for Device { + fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + use Device::*; + write!( + f, + "{}", + match self { + Linux => "Linux", + Mac => "Mac", + Windows => "Windows", + Iphone => "Iphone", + Android => "Android", + Unknown => "Unknown Device", + } + ) + } +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug)] +pub struct Location { + pub country: String, + pub state: String, + pub city: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct User { + pub device: Device, + pub ip: String, + pub user_agent: Option, + pub time: NaiveDateTime, + pub country: String, + pub state: String, + pub city: Option, +} + +impl From for User { + fn from( + TableUser { + ip, + user_agent, + device, + time, + country, + state, + city, + .. + }: TableUser, + ) -> Self { + Self { + device: Device::from(device), + ip, + user_agent, + time, + country, + state, + city, + } + } +} + +#[derive(Selectable, Clone, Debug)] +#[diesel(table_name = crate::schema::users)] +pub struct TableUser { + ip: String, + user_agent: Option, + device: String, + time: NaiveDateTime, + country: String, + state: String, + city: Option, +} + +#[derive(Insertable, Clone)] +#[diesel(table_name = crate::schema::users)] +pub struct NewUser<'a> { + ip: &'a str, + user_agent: Option>, + device: Cow<'a, str>, + time: &'a NaiveDateTime, + country: &'a str, + state: &'a str, + city: Option>, +} + +impl<'a, 'b: 'a> From<&'b User> for NewUser<'a> { + fn from( + User { + device, + ip, + user_agent, + time, + country, + state, + city, + .. + }: &'b User, + ) -> Self { + Self { + device: Cow::Owned(device.to_string()), + ip, + user_agent: user_agent.as_deref().map(Cow::Borrowed), + time, + country, + state, + city: city.as_deref().map(Cow::Borrowed), + } + } +}