From ff061cb3bff6c2c223b989a66bf79c092ccb6460 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 29 Aug 2025 23:22:37 +0000 Subject: [PATCH] major db refactors --- Cargo.lock | 12 +- Cargo.toml | 5 +- migrations/20240827001_initial_schema.sql | 3 +- src/commands/my_listings.rs | 28 +- src/commands/new_listing/mod.rs | 522 +++++++++--------- src/commands/new_listing/types.rs | 39 +- src/db/bind_fields.rs | 64 +++ src/db/dao/listing_dao.rs | 268 +++++---- src/db/dao/user_dao.rs | 255 +++++---- src/db/mod.rs | 1 + .../{new_listing.rs => draft_listing.rs} | 60 +- src/db/models/listing.rs | 357 +++--------- src/db/models/listing_type.rs | 2 +- src/db/models/mod.rs | 3 - src/db/models/user.rs | 29 +- .../{listing_id.rs => db_id/listing_db_id.rs} | 18 +- src/db/types/db_id/mod.rs | 7 + .../telegram_user_db_id.rs} | 22 +- .../{user_row_id.rs => db_id/user_db_id.rs} | 21 +- src/db/types/mod.rs | 8 +- src/test_utils.rs | 50 -- 21 files changed, 867 insertions(+), 907 deletions(-) create mode 100644 src/db/bind_fields.rs rename src/db/models/{new_listing.rs => draft_listing.rs} (67%) rename src/db/types/{listing_id.rs => db_id/listing_db_id.rs} (82%) create mode 100644 src/db/types/db_id/mod.rs rename src/db/types/{telegram_user_id.rs => db_id/telegram_user_db_id.rs} (75%) rename src/db/types/{user_row_id.rs => db_id/user_db_id.rs} (77%) diff --git a/Cargo.lock b/Cargo.lock index 64a7419..fe98b82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" dependencies = [ "include_dir", - "itertools", + "itertools 0.10.5", "proc-macro-error2", "proc-macro2", "quote", @@ -1213,6 +1213,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1593,6 +1602,7 @@ dependencies = [ "dotenvy", "env_logger", "futures", + "itertools 0.14.0", "lazy_static", "log", "num", diff --git a/Cargo.toml b/Cargo.toml index a42f448..871e267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +serde = { version = "1.0.219" } teloxide = { version = "0.17.0", features = ["macros", "ctrlc_handler"] } tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } sqlx = { version = "0.8.6", features = [ @@ -14,17 +15,17 @@ sqlx = { version = "0.8.6", features = [ "rust_decimal", ] } rust_decimal = { version = "1.33" } -chrono = { version = "0.4" } +chrono = { version = "0.4", features = ["serde"] } log = "0.4" env_logger = "0.11.8" anyhow = "1.0" dotenvy = "0.15" lazy_static = "1.4" -serde = "1.0.219" futures = "0.3.31" thiserror = "2.0.16" teloxide-core = "0.13.0" num = "0.4.3" +itertools = "0.14.0" [dev-dependencies] rstest = "0.26.1" diff --git a/migrations/20240827001_initial_schema.sql b/migrations/20240827001_initial_schema.sql index 4ec81f1..3a687fa 100644 --- a/migrations/20240827001_initial_schema.sql +++ b/migrations/20240827001_initial_schema.sql @@ -9,7 +9,8 @@ CREATE TABLE users ( id INTEGER PRIMARY KEY, telegram_id INTEGER UNIQUE NOT NULL, username TEXT, - display_name TEXT, + first_name TEXT, + last_name TEXT, is_banned INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP diff --git a/src/commands/my_listings.rs b/src/commands/my_listings.rs index 0cc4466..558df59 100644 --- a/src/commands/my_listings.rs +++ b/src/commands/my_listings.rs @@ -1,6 +1,6 @@ use crate::{ case, - db::{Listing, ListingDAO, ListingId, User, UserDAO}, + db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO}, keyboard_buttons, message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, Command, DialogueRootState, HandlerResult, RootDialogue, @@ -17,8 +17,8 @@ use teloxide::{ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MyListingsState { ViewingListings, - ManagingListing(ListingId), - EditingListing(ListingId), + ManagingListing(ListingDbId), + EditingListing(ListingDbId), } impl From for DialogueRootState { fn from(state: MyListingsState) -> Self { @@ -99,7 +99,7 @@ async fn show_listings_for_user( // Transition to ViewingListings state dialogue.update(MyListingsState::ViewingListings).await?; - let listings = ListingDAO::find_by_seller(&db_pool, user.id).await?; + let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?; if listings.is_empty() { send_message( &bot, @@ -118,7 +118,7 @@ async fn show_listings_for_user( for listing in &listings { keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback( listing.base.title.to_string(), - listing.base.id.to_string(), + listing.persisted.id.to_string(), )]); } @@ -132,7 +132,7 @@ async fn show_listings_for_user( for listing in &listings { response.push_str(&format!( "โ€ข ID {}: {}\n", - listing.base.id, listing.base.title + listing.persisted.id, listing.base.title )); } @@ -150,7 +150,7 @@ async fn handle_viewing_listings_callback( let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let target = (from.clone(), message_id); - let listing_id = ListingId::new(data.parse::()?); + let listing_id = ListingDbId::new(data.parse::()?); let (_, listing) = get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; dialogue @@ -163,7 +163,7 @@ async fn handle_viewing_listings_callback( async fn show_listing_details( bot: &Bot, - listing: Listing, + listing: PersistedListing, target: impl Into, ) -> HandlerResult { let response = format!( @@ -177,7 +177,7 @@ async fn show_listing_details( .description .as_deref() .unwrap_or("No description"), - listing.base.id + listing.persisted.id ); send_message( @@ -195,7 +195,7 @@ async fn handle_managing_listing_callback( bot: Bot, dialogue: RootDialogue, callback_query: CallbackQuery, - listing_id: ListingId, + listing_id: ListingDbId, ) -> HandlerResult { let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let target = (from.clone(), message_id); @@ -208,7 +208,7 @@ async fn handle_managing_listing_callback( let (_, listing) = get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; dialogue - .update(MyListingsState::EditingListing(listing.base.id)) + .update(MyListingsState::EditingListing(listing.persisted.id)) .await?; } ManageListingButtons::Delete => { @@ -228,9 +228,9 @@ async fn get_user_and_listing( db_pool: &SqlitePool, bot: &Bot, user_id: teloxide::types::UserId, - listing_id: ListingId, + listing_id: ListingDbId, target: impl Into, -) -> HandlerResult<(User, Listing)> { +) -> HandlerResult<(PersistedUser, PersistedListing)> { let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? { Some(user) => user, None => { @@ -253,7 +253,7 @@ async fn get_user_and_listing( } }; - if listing.base.seller_id != user.id { + if listing.base.seller_id != user.persisted.id { send_message( bot, target, diff --git a/src/commands/new_listing/mod.rs b/src/commands/new_listing/mod.rs index 9348c9d..6caba9f 100644 --- a/src/commands/new_listing/mod.rs +++ b/src/commands/new_listing/mod.rs @@ -5,14 +5,12 @@ mod validations; use crate::{ db::{ - dao::ListingDAO, - models::new_listing::{NewListing, NewListingBase, NewListingFields}, - ListingDuration, NewUser, UserDAO, + listing::{ListingFields, NewListing, PersistedListing}, + ListingDAO, ListingDuration, UserDAO, }, message_utils::*, DialogueRootState, HandlerResult, RootDialogue, }; -use chrono::{Duration, Utc}; pub use handler_factory::new_listing_handler; use keyboard::*; use log::{error, info}; @@ -33,10 +31,7 @@ fn create_back_button_keyboard() -> InlineKeyboardMarkup { fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup { create_single_row_keyboard(&[ ("๐Ÿ”™ Back", "edit_back"), - ( - &format!("๐Ÿงน Clear {field}"), - &format!("edit_clear_{field}"), - ), + (&format!("๐Ÿงน Clear {field}"), &format!("edit_clear_{field}")), ]) } @@ -50,6 +45,7 @@ fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup { // Handle the /newlisting command - starts the dialogue by setting it to Start state async fn handle_new_listing_command( + db_pool: SqlitePool, bot: Bot, dialogue: RootDialogue, msg: Message, @@ -58,12 +54,14 @@ async fn handle_new_listing_command( "User {} started new fixed price listing wizard", HandleAndId::from_chat(&msg.chat), ); + let user = msg.from.ok_or_else(|| anyhow::anyhow!("User not found"))?; + let user = UserDAO::find_or_create_by_telegram_user(&db_pool, user).await?; // Initialize the dialogue to Start state dialogue .update(NewListingState::AwaitingDraftField { field: ListingField::Title, - draft: ListingDraft::default(), + draft: ListingDraft::draft_for_seller(user.persisted.id), }) .await?; @@ -77,7 +75,7 @@ async fn handle_new_listing_command( } async fn handle_awaiting_draft_field_input( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), msg: Message, @@ -108,7 +106,7 @@ async fn handle_awaiting_draft_field_input( } async fn handle_title_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, @@ -116,7 +114,7 @@ async fn handle_title_input( ) -> HandlerResult { match validate_title(text) { Ok(title) => { - draft.title = title; + draft.base.title = title; dialogue .update(NewListingState::AwaitingDraftField { field: ListingField::Description, @@ -135,13 +133,13 @@ async fn handle_title_input( } async fn handle_description_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, mut draft: ListingDraft, ) -> HandlerResult { - draft.description = match validate_description(text) { + draft.base.description = match validate_description(text) { Ok(description) => Some(description), Err(error_msg) => { send_message(&bot, chat, error_msg, None).await?; @@ -165,7 +163,7 @@ async fn handle_description_input( } async fn handle_description_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, draft: ListingDraft, data: &str, @@ -198,7 +196,7 @@ async fn handle_description_callback( } async fn handle_awaiting_draft_field_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), callback_query: CallbackQuery, @@ -238,7 +236,7 @@ async fn handle_awaiting_draft_field_callback( } async fn handle_slots_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, draft: ListingDraft, data: &str, @@ -257,7 +255,7 @@ async fn handle_slots_callback( } async fn handle_start_time_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, draft: ListingDraft, data: &str, @@ -282,8 +280,21 @@ async fn process_slots_and_respond( slots: i32, ) -> HandlerResult { let target = target.into(); + match &mut draft.fields { + ListingFields::FixedPriceListing { + slots_available, .. + } => { + *slots_available = slots; + } + _ => { + return Err(anyhow::anyhow!( + "Unsupported listing type to update slots: {:?}", + draft.fields + )); + } + }; + // Update dialogue state - draft.slots_available = slots; dialogue .update(NewListingState::AwaitingDraftField { field: ListingField::StartTime, @@ -320,7 +331,7 @@ async fn handle_viewing_draft_callback( callback_query: CallbackQuery, ) -> HandlerResult { let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; - let target = (from.clone(), message_id); + let target = (from, message_id); let button = ConfirmationKeyboardButtons::try_from(data.as_str()) .map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?; @@ -329,10 +340,10 @@ async fn handle_viewing_draft_callback( ConfirmationKeyboardButtons::Create => { info!("User {target:?} confirmed listing creation"); dialogue.exit().await?; - create_listing(db_pool, bot, dialogue, from, message_id, draft.clone()).await?; + save_listing(db_pool, bot, dialogue, target, draft).await?; } ConfirmationKeyboardButtons::Discard => { - info!("User {from:?} discarded listing creation"); + info!("User {target:?} discarded listing creation"); // Exit dialogue and send cancellation message dialogue.exit().await?; @@ -344,15 +355,15 @@ async fn handle_viewing_draft_callback( send_message(&bot, target, &response, None).await?; } ConfirmationKeyboardButtons::Edit => { - info!("User {from:?} chose to edit listing"); + info!("User {target:?} chose to edit listing"); + + // Delete the old message and show the edit screen + show_edit_screen(&bot, target, &draft, None).await?; // Go to editing state to allow user to modify specific fields dialogue - .update(NewListingState::EditingDraft(draft.clone())) + .update(NewListingState::EditingDraft(draft)) .await?; - - // Delete the old message and show the edit screen - show_edit_screen(bot, target, draft, None).await?; } } @@ -368,8 +379,18 @@ async fn process_start_time_and_respond( duration: ListingDuration, ) -> HandlerResult { let target = target.into(); + // Update dialogue state - draft.start_delay = duration; + + match &mut draft.persisted { + ListingDraftPersisted::New(fields) => { + fields.start_delay = duration; + } + ListingDraftPersisted::Persisted(_) => { + anyhow::bail!("Cannot update start time for persisted listing"); + } + } + dialogue .update(NewListingState::AwaitingDraftField { field: ListingField::Duration, @@ -399,7 +420,7 @@ async fn process_start_time_and_respond( } async fn handle_price_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, @@ -407,14 +428,21 @@ async fn handle_price_input( ) -> HandlerResult { match validate_price(text) { Ok(price) => { - draft.buy_now_price = price; + match &mut draft.fields { + ListingFields::FixedPriceListing { buy_now_price, .. } => { + *buy_now_price = price; + } + _ => { + anyhow::bail!("Cannot update price for non-fixed price listing"); + } + } let response = format!( "โœ… Price saved: ${}\n\n\ Step 4 of 6: Available Slots\n\ How many items are available for sale?\n\n\ Choose a common value below or enter a custom number (1-1000):", - draft.buy_now_price + price ); dialogue @@ -439,7 +467,7 @@ async fn handle_price_input( } async fn handle_slots_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, @@ -458,7 +486,7 @@ async fn handle_slots_input( } async fn handle_start_time_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, @@ -483,7 +511,7 @@ async fn handle_start_time_input( } async fn handle_duration_input( - bot: Bot, + bot: &Bot, chat: Chat, text: &str, dialogue: RootDialogue, @@ -501,7 +529,7 @@ async fn handle_duration_input( } async fn handle_duration_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, draft: ListingDraft, data: &str, @@ -518,108 +546,116 @@ async fn handle_duration_callback( } async fn process_duration_and_respond( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, mut draft: ListingDraft, target: impl Into, duration: ListingDuration, ) -> HandlerResult { let target = target.into(); - draft.duration = duration; + match &mut draft.persisted { + ListingDraftPersisted::New(fields) => { + fields.end_delay = duration; + } + ListingDraftPersisted::Persisted(_) => { + anyhow::bail!("Cannot update duration for persisted listing"); + } + } + + show_confirmation_screen(bot, target, &draft).await?; dialogue - .update(NewListingState::ViewingDraft(draft.clone())) + .update(NewListingState::ViewingDraft(draft)) .await?; - show_confirmation(bot, target, draft).await + + Ok(()) } -async fn show_confirmation( - bot: Bot, +async fn display_listing_summary( + bot: &Bot, target: impl Into, - state: ListingDraft, + draft: &ListingDraft, + keyboard: Option, + flash_message: Option<&str>, ) -> HandlerResult { - let description_text = state - .description - .as_deref() - .unwrap_or("No description"); + let mut response_lines = vec![]; - let start_time_str = format!("In {}", state.start_delay); + if let Some(flash_message) = flash_message { + response_lines.push(flash_message.to_string()); + } - let response = format!( - "๐Ÿ“‹ Listing Summary\n\n\ - Title: {}\n\ - Description: {}\n\ - Price: ${}\n\ - Available Slots: {}\n\ - Start Time: {}\n\ - Duration: {}\n\n\ - Please review your listing and choose an action:", - state.title, - description_text, - state.buy_now_price, - state.slots_available, - start_time_str, - state.duration - ); + response_lines.push("๐Ÿ“‹ Listing Summary".to_string()); + response_lines.push("".to_string()); + response_lines.push(format!("Title: {}", draft.base.title)); + response_lines.push(format!( + "๐Ÿ“„ Description: {}", + draft + .base + .description + .as_deref() + .unwrap_or("No description") + )); - send_message( - &bot, - target, - &response, - Some(ConfirmationKeyboardButtons::to_keyboard()), - ) - .await?; + match &draft.fields { + ListingFields::FixedPriceListing { buy_now_price, .. } => { + response_lines.push(format!("๐Ÿ’ฐ Buy it Now Price: ${}", buy_now_price)); + } + _ => {} + } + + match &draft.persisted { + ListingDraftPersisted::New(fields) => { + response_lines.push(format!("Start delay: {}", fields.start_delay)); + response_lines.push(format!("Duration: {}", fields.end_delay)); + } + ListingDraftPersisted::Persisted(fields) => { + response_lines.push(format!("Starts on: {}", fields.start_at)); + response_lines.push(format!("Ends on: {}", fields.end_at)); + } + } + + response_lines.push("".to_string()); + response_lines.push("Please review your listing and choose an action:".to_string()); + + send_message(&bot, target, response_lines.join("\n"), keyboard).await?; Ok(()) } async fn show_edit_screen( - bot: Bot, + bot: &Bot, target: impl Into, - state: ListingDraft, + draft: &ListingDraft, flash_message: Option<&str>, ) -> HandlerResult { - let target = target.into(); - let description_text = state - .description - .as_deref() - .unwrap_or("No description"); - - let start_time_str = format!("In {}", state.start_delay); - - let mut response = format!( - "โœ๏ธ Editing Listing:\n\n\ - ๐Ÿ“ Title: {}\n\ - ๐Ÿ“„ Description: {}\n\ - ๐Ÿ’ฐ Price: ${}\n\ - ๐Ÿ”ข Available Slots: {}\n\ - โฐ Start Time: {}\n\ - โณ Duration: {}\n\n\ - Select a field to edit:", - state.title, - description_text, - state.buy_now_price, - state.slots_available, - start_time_str, - state.duration - ); - - if let Some(flash_message) = flash_message { - response = format!("{flash_message}\n\n{response}"); - } - - send_message( - &bot, + display_listing_summary( + bot, target, - &response, + draft, Some(FieldSelectionKeyboardButtons::to_keyboard()), + flash_message, ) .await?; + Ok(()) +} +async fn show_confirmation_screen( + bot: &Bot, + target: impl Into, + draft: &ListingDraft, +) -> HandlerResult { + display_listing_summary( + bot, + target, + draft, + Some(ConfirmationKeyboardButtons::to_keyboard()), + None, + ) + .await?; Ok(()) } async fn handle_editing_field_input( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), msg: Message, @@ -654,30 +690,28 @@ async fn handle_editing_field_input( } async fn handle_editing_draft_callback( - bot: Bot, + bot: &Bot, draft: ListingDraft, dialogue: RootDialogue, callback_query: CallbackQuery, ) -> HandlerResult { let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; - let target = (from.clone(), message_id); + let target = (from, message_id); let button = FieldSelectionKeyboardButtons::try_from(data.as_str()) .map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?; - info!( - "User {} in editing screen, showing field selection", - HandleAndId::from_user(&from) - ); + info!("User {target:?} in editing screen, showing field selection"); let (field, value, keyboard) = match button { FieldSelectionKeyboardButtons::Title => ( ListingField::Title, - draft.title.clone(), + draft.base.title.clone(), create_back_button_keyboard(), ), FieldSelectionKeyboardButtons::Description => ( ListingField::Description, draft + .base .description .as_deref() .unwrap_or("(no description)") @@ -686,41 +720,58 @@ async fn handle_editing_draft_callback( ), FieldSelectionKeyboardButtons::Price => ( ListingField::Price, - format!("${}", draft.buy_now_price), + match &draft.fields { + ListingFields::FixedPriceListing { buy_now_price, .. } => { + format!("${}", buy_now_price) + } + _ => anyhow::bail!("Cannot update price for non-fixed price listing"), + }, create_back_button_keyboard(), ), FieldSelectionKeyboardButtons::Slots => ( ListingField::Slots, - format!("{} slots", draft.slots_available), + match &draft.fields { + ListingFields::FixedPriceListing { + slots_available, .. + } => { + format!("{} slots", slots_available) + } + _ => anyhow::bail!("Cannot update slots for non-fixed price listing"), + }, create_back_button_keyboard_with(SlotsKeyboardButtons::to_keyboard()), ), FieldSelectionKeyboardButtons::StartTime => ( ListingField::StartTime, - draft.start_delay.to_string(), + match &draft.persisted { + ListingDraftPersisted::New(fields) => { + format!("{} hours", fields.start_delay) + } + _ => anyhow::bail!("Cannot update start time of an existing listing"), + }, create_back_button_keyboard_with(StartTimeKeyboardButtons::to_keyboard()), ), FieldSelectionKeyboardButtons::Duration => ( ListingField::Duration, - draft.duration.to_string(), + match &draft.persisted { + ListingDraftPersisted::New(fields) => fields.end_delay.to_string(), + _ => anyhow::bail!("Cannot update duration of an existing listing"), + }, create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()), ), FieldSelectionKeyboardButtons::Done => { + show_confirmation_screen(bot, target, &draft).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::ViewingDraft(draft.clone()), + NewListingState::ViewingDraft(draft), )) .await?; - show_confirmation(bot, target, draft).await?; return Ok(()); } }; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraftField { - field, - draft: draft.clone(), - }, + NewListingState::EditingDraftField { field, draft }, )) .await?; @@ -736,99 +787,55 @@ async fn handle_editing_draft_callback( Ok(()) } -async fn create_listing( +async fn save_listing( db_pool: SqlitePool, bot: Bot, dialogue: RootDialogue, - from: User, - message_id: MessageId, + target: impl Into, draft: ListingDraft, ) -> HandlerResult { - let now = Utc::now(); - let starts_at = now + Into::::into(draft.start_delay); - let ends_at = starts_at + Into::::into(draft.duration); - - let user = match UserDAO::find_by_telegram_id(&db_pool, from.id).await? { - Some(user) => user, - None => { - UserDAO::insert_user( + let listing: PersistedListing = match draft.persisted { + ListingDraftPersisted::New(fields) => { + ListingDAO::insert_listing( &db_pool, - &NewUser { - telegram_id: from.id.into(), - username: from.username.clone(), - display_name: Some(from.first_name.clone()), + NewListing { + persisted: fields, + base: draft.base, + fields: draft.fields, + }, + ) + .await? + } + ListingDraftPersisted::Persisted(fields) => { + ListingDAO::update_listing( + &db_pool, + PersistedListing { + persisted: fields, + base: draft.base, + fields: draft.fields, }, ) .await? } }; - let new_listing_base = NewListingBase::new( - user.id, - draft.title.clone(), - draft.description.clone(), - starts_at, - ends_at, - ); - - let new_listing = NewListing { - base: new_listing_base, - fields: NewListingFields::FixedPriceListing { - buy_now_price: draft.buy_now_price, - slots_available: draft.slots_available, - }, - }; - - match ListingDAO::insert_listing(&db_pool, &new_listing).await { - Ok(listing) => { - let response = format!( - "โœ… Listing Created Successfully!\n\n\ + let response = format!( + "โœ… Listing Created Successfully!\n\n\ Listing ID: {}\n\ Title: {}\n\ - Price: ${}\n\ - Slots Available: {}\n\n\ Your fixed price listing is now live! ๐ŸŽ‰", - listing.base.id, listing.base.title, draft.buy_now_price, draft.slots_available - ); + listing.persisted.id, listing.base.title + ); - send_message(&bot, (from.clone(), message_id), response, None).await?; - dialogue.exit().await?; - - info!( - "Fixed price listing created successfully for user {:?}: {:?}", - from.id, listing.base.id - ); - } - Err(e) => { - log::error!("Failed to create listing for user {from:?}: {e}"); - send_message( - &bot, - (from, message_id), - "โŒ Error: Failed to create listing. Please try again later.", - None, - ) - .await?; - } - } - - Ok(()) -} - -async fn cancel_wizard( - bot: Bot, - dialogue: RootDialogue, - target: impl Into, -) -> HandlerResult { - let target = target.into(); - info!("{target:?} cancelled new listing wizard"); dialogue.exit().await?; - send_message(&bot, target, "โŒ Listing creation cancelled.", None).await?; + send_message(&bot, target, response, None).await?; + Ok(()) } // Individual field editing handlers async fn handle_edit_title( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, mut draft: ListingDraft, text: &str, @@ -837,7 +844,7 @@ async fn handle_edit_title( let target = target.into(); info!("User {target:?} editing title: '{text}'"); - draft.title = match validate_title(text) { + draft.base.title = match validate_title(text) { Ok(title) => title, Err(error_msg) => { send_message( @@ -852,27 +859,26 @@ async fn handle_edit_title( }; // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Title updated!")).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(draft.clone()), + NewListingState::EditingDraft(draft), )) .await?; - - show_edit_screen(bot, target, draft, Some("โœ… Title updated!")).await?; Ok(()) } async fn handle_edit_description( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, - mut state: ListingDraft, + mut draft: ListingDraft, text: &str, target: impl Into, ) -> HandlerResult { let target = target.into(); info!("User {target:?} editing description: '{text}'"); - state.description = match validate_description(text) { + draft.base.description = match validate_description(text) { Ok(description) => Some(description), Err(error_msg) => { send_message(&bot, target, error_msg, None).await?; @@ -881,28 +887,31 @@ async fn handle_edit_description( }; // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Description updated!")).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(state.clone()), + NewListingState::EditingDraft(draft), )) .await?; - - show_edit_screen(bot, target, state, Some("โœ… Description updated!")).await?; - Ok(()) } async fn handle_edit_price( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, - mut state: ListingDraft, + mut draft: ListingDraft, text: &str, target: impl Into, ) -> HandlerResult { let target = target.into(); info!("User {target:?} editing price: '{text}'"); - state.buy_now_price = match validate_price(text) { + let buy_now_price = match &mut draft.fields { + ListingFields::FixedPriceListing { buy_now_price, .. } => buy_now_price, + _ => anyhow::bail!("Cannot update price for non-fixed price listing"), + }; + + *buy_now_price = match validate_price(text) { Ok(price) => price, Err(error_msg) => { send_message(&bot, target, error_msg, None).await?; @@ -911,27 +920,35 @@ async fn handle_edit_price( }; // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Price updated!")).await?; + dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(state.clone()), + NewListingState::EditingDraft(draft), )) .await?; - show_edit_screen(bot, target, state, Some("โœ… Price updated!")).await?; Ok(()) } async fn handle_edit_slots( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, - mut state: ListingDraft, + mut draft: ListingDraft, text: &str, target: impl Into, ) -> HandlerResult { let target = target.into(); info!("User {target:?} editing slots: '{text}'"); - state.slots_available = match validate_slots(text) { + let slots_available = match &mut draft.fields { + ListingFields::FixedPriceListing { + slots_available, .. + } => slots_available, + _ => anyhow::bail!("Cannot update slots for non-fixed price listing"), + }; + + *slots_available = match validate_slots(text) { Ok(s) => s, Err(error_msg) => { send_message(&bot, target, error_msg, None).await?; @@ -939,106 +956,97 @@ async fn handle_edit_slots( } }; - // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Slots updated!")).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(state.clone()), + NewListingState::EditingDraft(draft), )) .await?; - show_edit_screen(bot, target, state, Some("โœ… Slots updated!")).await?; - Ok(()) } async fn handle_edit_start_time( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, - mut state: ListingDraft, + mut draft: ListingDraft, text: &str, target: impl Into, ) -> HandlerResult { let target = target.into(); info!("User {target:?} editing start time: '{text}'"); - state.start_delay = match validate_start_time(text) { + let fields = match &mut draft.persisted { + ListingDraftPersisted::New(fields) => fields, + _ => anyhow::bail!("Cannot update start time of an existing listing"), + }; + + fields.start_delay = match validate_start_time(text) { Ok(h) => h, - _ => { - send_message( - &bot, - target, - "โŒ Invalid number. Please enter hours from now (0-168):", - Some(create_back_button_keyboard()), - ) - .await?; + Err(error_msg) => { + send_message(&bot, target, error_msg, Some(create_back_button_keyboard())).await?; return Ok(()); } }; // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Start time updated!")).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(state.clone()), + NewListingState::EditingDraft(draft), )) .await?; - - show_edit_screen(bot, target, state, Some("โœ… Start time updated!")).await?; - Ok(()) } async fn handle_edit_duration( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, - mut state: ListingDraft, + mut draft: ListingDraft, text: &str, target: impl Into, ) -> HandlerResult { let target = target.into(); info!("User {target:?} editing duration: '{text}'"); - state.duration = match validate_duration(text) { + let fields = match &mut draft.persisted { + ListingDraftPersisted::New(fields) => fields, + _ => anyhow::bail!("Cannot update duration of an existing listing"), + }; + + fields.end_delay = match validate_duration(text) { Ok(d) => d, - _ => { - send_message( - &bot, - target, - "โŒ Invalid number. Please enter duration in hours (1-720):", - Some(create_back_button_keyboard()), - ) - .await?; + Err(error_msg) => { + send_message(&bot, target, error_msg, Some(create_back_button_keyboard())).await?; return Ok(()); } }; - // Go back to editing listing state + show_edit_screen(bot, target, &draft, Some("โœ… Duration updated!")).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(state.clone()), + NewListingState::EditingDraft(draft), )) .await?; - - show_edit_screen(bot, target, state, Some("โœ… Duration updated!")).await?; - Ok(()) } async fn handle_editing_draft_field_callback( - bot: Bot, + bot: &Bot, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), callback_query: CallbackQuery, ) -> HandlerResult { let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; - let target = (from.clone(), message_id); + let target = (from, message_id); info!("User {:?} editing field: {:?} -> {}", target, field, &data); if data == "edit_back" { + show_edit_screen(bot, target, &draft, None).await?; dialogue .update(DialogueRootState::NewListing( - NewListingState::EditingDraft(draft.clone()), + NewListingState::EditingDraft(draft), )) .await?; - show_edit_screen(bot, target, draft, None).await?; return Ok(()); } @@ -1065,3 +1073,15 @@ async fn handle_editing_draft_field_callback( Ok(()) } + +async fn cancel_wizard( + bot: &Bot, + dialogue: RootDialogue, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("{target:?} cancelled new listing wizard"); + dialogue.exit().await?; + send_message(&bot, target, "โŒ Listing creation cancelled.", None).await?; + Ok(()) +} diff --git a/src/commands/new_listing/types.rs b/src/commands/new_listing/types.rs index 4616a33..a1729e4 100644 --- a/src/commands/new_listing/types.rs +++ b/src/commands/new_listing/types.rs @@ -1,17 +1,40 @@ use crate::{ - db::{ListingDuration, MoneyAmount}, + db::{ + listing::{ListingBase, ListingFields, NewListingFields, PersistedListingFields}, + MoneyAmount, UserDbId, + }, DialogueRootState, }; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct ListingDraft { - pub title: String, - pub description: Option, - pub buy_now_price: MoneyAmount, - pub slots_available: i32, - pub start_delay: ListingDuration, - pub duration: ListingDuration, + pub persisted: ListingDraftPersisted, + pub base: ListingBase, + pub fields: ListingFields, +} + +impl ListingDraft { + pub fn draft_for_seller(seller_id: UserDbId) -> Self { + Self { + persisted: ListingDraftPersisted::New(NewListingFields::default()), + base: ListingBase { + seller_id, + title: "".to_string(), + description: None, + }, + fields: ListingFields::FixedPriceListing { + buy_now_price: MoneyAmount::default(), + slots_available: 0, + }, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub enum ListingDraftPersisted { + New(NewListingFields), + Persisted(PersistedListingFields), } #[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] diff --git a/src/db/bind_fields.rs b/src/db/bind_fields.rs new file mode 100644 index 0000000..87855e9 --- /dev/null +++ b/src/db/bind_fields.rs @@ -0,0 +1,64 @@ +use std::iter::repeat; + +use sqlx::{prelude::*, query::Query, sqlite::SqliteArguments, Encode, Sqlite}; + +type BindFn = Box< + dyn for<'q> FnOnce( + Query<'q, Sqlite, SqliteArguments<'q>>, + ) -> Query<'q, Sqlite, SqliteArguments<'q>> + + Send, +>; + +fn make_bind_fn(value: T) -> BindFn +where + T: for<'q> Encode<'q, Sqlite> + Type + Send + 'static, +{ + Box::new(move |query| query.bind(value)) +} + +pub struct BindFields { + binds: Vec<(&'static str, BindFn)>, +} + +impl Default for BindFields { + fn default() -> Self { + Self { binds: vec![] } + } +} + +impl BindFields { + #[must_use] + pub fn push<'a>( + mut self, + field: &'static str, + value: &'a (impl for<'q> Encode<'q, Sqlite> + Type + Send + 'static + Clone), + ) -> Self { + self.binds.push((field, make_bind_fn(value.clone()))); + self + } + + #[must_use] + pub fn extend(mut self, other: Self) -> Self { + self.binds.extend(other.binds); + self + } + + pub fn bind_to_query<'q>( + self, + query: Query<'q, Sqlite, SqliteArguments<'q>>, + ) -> Query<'q, Sqlite, SqliteArguments<'q>> { + let mut query = query; + for (_, bind_fn) in self.binds { + query = bind_fn(query); + } + query + } + + pub fn bind_names(&self) -> impl Iterator + '_ { + self.binds.iter().map(|(name, _)| *name) + } + + pub fn bind_placeholders(&self) -> impl Iterator + '_ { + repeat("?").take(self.binds.len()) + } +} diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 70613d2..87445df 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -3,136 +3,131 @@ //! Provides encapsulated CRUD operations for Listing entities use anyhow::Result; -use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; +use chrono::{Duration, Utc}; +use itertools::Itertools; +use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool}; +use std::fmt::Debug; use crate::db::{ - new_listing::{NewListing, NewListingFields}, - ListingBase, ListingFields, + bind_fields::BindFields, + listing::{ + Listing, ListingBase, ListingFields, NewListing, PersistedListing, PersistedListingFields, + }, + ListingDbId, ListingType, UserDbId, }; -use super::super::{Listing, ListingId, ListingType, UserRowId}; - /// Data Access Object for Listing operations pub struct ListingDAO; +const LISTING_RETURN_FIELDS: &[&str] = &[ + "id", + "seller_id", + "listing_type", + "title", + "description", + "starts_at", + "ends_at", + "created_at", + "updated_at", + "starting_bid", + "buy_now_price", + "min_increment", + "anti_snipe_minutes", + "slots_available", +]; + impl ListingDAO { /// Insert a new listing into the database - pub async fn insert_listing(pool: &SqlitePool, new_listing: &NewListing) -> Result { - let listing_type = new_listing.listing_type(); - let base = &new_listing.base; - let fields = &new_listing.fields; + pub async fn insert_listing( + pool: &SqlitePool, + listing: NewListing, + ) -> Result { + let now = Utc::now(); + let start_at = now + Into::::into(listing.persisted.start_delay); + let end_at = start_at + Into::::into(listing.persisted.end_delay); - let base_query = match listing_type { - ListingType::BasicAuction => sqlx::query( - r#" - INSERT INTO listings ( - seller_id, listing_type, title, description, starts_at, ends_at, - starting_bid, buy_now_price, min_increment, anti_snipe_minutes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at, - starting_bid, buy_now_price, min_increment, anti_snipe_minutes - "#, - ), - ListingType::MultiSlotAuction => sqlx::query( - r#" - INSERT INTO listings ( - seller_id, listing_type, title, description, starts_at, ends_at, - starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at, - starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes - "#, - ), - ListingType::FixedPriceListing => sqlx::query( - r#" - INSERT INTO listings ( - seller_id, listing_type, title, description, starts_at, ends_at, - buy_now_price, slots_available - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at, - buy_now_price, slots_available - "#, - ), - ListingType::BlindAuction => sqlx::query( - r#" - INSERT INTO listings ( - seller_id, listing_type, title, description, starts_at, ends_at, - starting_bid - ) VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at, - starting_bid - "#, - ), - }; + let binds = binds_for_listing(&listing) + .push("seller_id", &listing.base.seller_id) + .push("starts_at", &start_at) + .push("ends_at", &end_at) + .push("created_at", &now) + .push("updated_at", &now); - let row = base_query - .bind(base.seller_id) - .bind(listing_type) - .bind(&base.title) - .bind(&base.description) - .bind(base.starts_at) - .bind(base.ends_at); + let query_str = format!( + r#" + INSERT INTO listings ({}) VALUES ({}) + RETURNING {} + "#, + binds.bind_names().join(", "), + binds.bind_placeholders().join(", "), + LISTING_RETURN_FIELDS.join(", ") + ); - let row = match &fields { - NewListingFields::BasicAuction { - starting_bid, - buy_now_price, - min_increment, - anti_snipe_minutes, - } => row - .bind(starting_bid) - .bind(buy_now_price) - .bind(min_increment) - .bind(anti_snipe_minutes), - NewListingFields::MultiSlotAuction { - starting_bid, - buy_now_price, - min_increment, - slots_available, - anti_snipe_minutes, - } => row - .bind(starting_bid) - .bind(buy_now_price) - .bind(min_increment) - .bind(slots_available) - .bind(anti_snipe_minutes), - NewListingFields::FixedPriceListing { - buy_now_price, - slots_available, - } => row.bind(buy_now_price).bind(slots_available), - NewListingFields::BlindAuction { starting_bid } => row.bind(starting_bid), - }; + let row = binds + .bind_to_query(sqlx::query(&query_str)) + .fetch_one(pool) + .await?; + Ok(FromRow::from_row(&row)?) + } - let row = row.fetch_one(pool).await?; - Self::row_to_listing(row) + pub async fn update_listing( + pool: &SqlitePool, + listing: PersistedListing, + ) -> Result { + let now = Utc::now(); + let binds = binds_for_listing(&listing).push("updated_at", &now); + + let query_str = format!( + r#" + UPDATE listings + SET {} + WHERE id = ? AND seller_id = ? + RETURNING {} + "#, + binds + .bind_names() + .map(|name| format!("{name} = ?")) + .join(", "), + LISTING_RETURN_FIELDS.join(", ") + ); + + let row = binds + .bind_to_query(sqlx::query(&query_str)) + .bind(listing.persisted.id) + .bind(listing.base.seller_id) + .fetch_one(pool) + .await?; + Ok(FromRow::from_row(&row)?) } /// Find a listing by its ID - pub async fn find_by_id(pool: &SqlitePool, listing_id: ListingId) -> Result> { - let result = sqlx::query("SELECT * FROM listings WHERE id = ?") + pub async fn find_by_id( + pool: &SqlitePool, + listing_id: ListingDbId, + ) -> Result> { + let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?") .bind(listing_id) .fetch_optional(pool) .await?; - result.map(Self::row_to_listing).transpose() + Ok(result) } /// Find all listings by a seller - pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserRowId) -> Result> { + pub async fn find_by_seller( + pool: &SqlitePool, + seller_id: UserDbId, + ) -> Result> { let rows = - sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") + sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") .bind(seller_id) .fetch_all(pool) .await?; - - rows - .into_iter() - .map(Self::row_to_listing) - .collect::>>() + Ok(rows) } /// Delete a listing - pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> { + pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingDbId) -> Result<()> { sqlx::query("DELETE FROM listings WHERE id = ?") .bind(listing_id) .execute(pool) @@ -140,18 +135,73 @@ impl ListingDAO { Ok(()) } +} - fn row_to_listing(row: SqliteRow) -> Result { +fn binds_for_listing(listing: &Listing

) -> BindFields { + BindFields::default() + .extend(binds_for_base(&listing.base)) + .extend(binds_for_fields(&listing.fields)) +} + +fn binds_for_base(base: &ListingBase) -> BindFields { + BindFields::default() + .push("title", &base.title) + .push("description", &base.description) +} + +fn binds_for_fields(fields: &ListingFields) -> BindFields { + match fields { + ListingFields::BasicAuction { + starting_bid, + buy_now_price, + min_increment, + anti_snipe_minutes, + } => BindFields::default() + .push("listing_type", &ListingType::BasicAuction) + .push("starting_bid", starting_bid) + .push("buy_now_price", buy_now_price) + .push("min_increment", min_increment) + .push("anti_snipe_minutes", anti_snipe_minutes), + ListingFields::MultiSlotAuction { + starting_bid, + buy_now_price, + min_increment, + slots_available, + anti_snipe_minutes, + } => BindFields::default() + .push("listing_type", &ListingType::MultiSlotAuction) + .push("starting_bid", starting_bid) + .push("buy_now_price", buy_now_price) + .push("min_increment", min_increment) + .push("slots_available", slots_available) + .push("anti_snipe_minutes", anti_snipe_minutes), + ListingFields::FixedPriceListing { + buy_now_price, + slots_available, + } => BindFields::default() + .push("listing_type", &ListingType::FixedPriceListing) + .push("buy_now_price", buy_now_price) + .push("slots_available", slots_available), + ListingFields::BlindAuction { starting_bid } => BindFields::default() + .push("listing_type", &ListingType::BlindAuction) + .push("starting_bid", starting_bid), + } +} + +impl FromRow<'_, SqliteRow> for PersistedListing { + fn from_row(row: &'_ SqliteRow) -> std::result::Result { let listing_type = row.get("listing_type"); + let persisted = PersistedListingFields { + id: row.get("id"), + start_at: row.get("starts_at"), + end_at: row.get("ends_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }; let base = ListingBase { - id: ListingId::new(row.get("id")), seller_id: row.get("seller_id"), title: row.get("title"), description: row.get("description"), - starts_at: row.get("starts_at"), - ends_at: row.get("ends_at"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), }; let fields = match listing_type { ListingType::BasicAuction => ListingFields::BasicAuction { @@ -175,6 +225,10 @@ impl ListingDAO { starting_bid: row.get("starting_bid"), }, }; - Ok(Listing { base, fields }) + Ok(PersistedListing { + persisted, + base, + fields, + }) } } diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs index 9bb7026..e1b9e64 100644 --- a/src/db/dao/user_dao.rs +++ b/src/db/dao/user_dao.rs @@ -3,78 +3,131 @@ //! Provides encapsulated CRUD operations for User entities use anyhow::Result; -use sqlx::SqlitePool; +use itertools::Itertools as _; +use sqlx::{sqlite::SqliteRow, FromRow, SqlitePool}; use crate::db::{ - models::user::{NewUser, User}, - TelegramUserId, UserRowId, + bind_fields::BindFields, + models::user::NewUser, + user::{PersistedUser, PersistedUserFields}, + TelegramUserDbId, UserDbId, }; /// Data Access Object for User operations pub struct UserDAO; +const USER_RETURN_FIELDS: &[&str] = &[ + "id", + "telegram_id", + "username", + "first_name", + "last_name", + "is_banned", + "created_at", + "updated_at", +]; + #[allow(unused)] impl UserDAO { /// Insert a new user into the database - pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result { - let user = sqlx::query_as::<_, User>( - r#" - INSERT INTO users (telegram_id, username, display_name) - VALUES (?, ?, ?) - RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at - "#, - ) - .bind(new_user.telegram_id) - .bind(&new_user.username) - .bind(&new_user.display_name) - .fetch_one(pool) - .await?; + pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result { + let binds = BindFields::default() + .push("telegram_id", &new_user.telegram_id) + .push("first_name", &new_user.first_name) + .push("last_name", &new_user.last_name) + .push("username", &new_user.username); - Ok(user) + let query_str = format!( + r#" + INSERT INTO users ({}) + VALUES ({}) + RETURNING {} + "#, + binds.bind_names().join(", "), + binds.bind_placeholders().join(", "), + USER_RETURN_FIELDS.join(", ") + ); + let query = sqlx::query(&query_str); + let row = binds.bind_to_query(query).fetch_one(pool).await?; + + Ok(FromRow::from_row(&row)?) } /// Find a user by their ID - pub async fn find_by_id(pool: &SqlitePool, user_id: UserRowId) -> Result> { - let user = sqlx::query_as::<_, User>( - "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?" + pub async fn find_by_id(pool: &SqlitePool, user_id: UserDbId) -> Result> { + Ok(sqlx::query_as::<_, PersistedUser>( + r#" + SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at + FROM users + WHERE id = ? + "#, ) .bind(user_id) .fetch_optional(pool) - .await?; - - Ok(user) + .await?) } /// Find a user by their Telegram ID pub async fn find_by_telegram_id( pool: &SqlitePool, - telegram_id: impl Into, - ) -> Result> { + telegram_id: impl Into, + ) -> Result> { let telegram_id = telegram_id.into(); - let user = sqlx::query_as::<_, User>( - "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?" + Ok(sqlx::query_as( + r#" + SELECT id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at + FROM users + WHERE telegram_id = ? + "#, ) .bind(telegram_id) .fetch_optional(pool) + .await?) + } + + pub async fn find_or_create_by_telegram_user( + pool: &SqlitePool, + user: teloxide::types::User, + ) -> Result { + let mut tx = pool.begin().await?; + let telegram_id = TelegramUserDbId::from(user.id); + + let user = sqlx::query_as( + r#" + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES (?, ?, ?, ?) + ON CONFLICT (telegram_id) DO UPDATE SET + username = EXCLUDED.username, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name + RETURNING id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at + "#, + ) + .bind(telegram_id) + .bind(user.username) + .bind(user.first_name) + .bind(user.last_name) + .fetch_one(&mut *tx) .await?; Ok(user) } /// Update a user's information - pub async fn update_user(pool: &SqlitePool, user: &User) -> Result { - let updated_user = sqlx::query_as::<_, User>( + pub async fn update_user(pool: &SqlitePool, user: &PersistedUser) -> Result { + let updated_user = sqlx::query_as::<_, PersistedUser>( r#" UPDATE users - SET username = ?, display_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP + SET username = ?, first_name = ?, last_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at + RETURNING id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at "#, ) .bind(&user.username) - .bind(&user.display_name) + .bind(&user.first_name) + .bind(&user.last_name) .bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite - .bind(user.id) + .bind(user.persisted.id) .fetch_one(pool) .await?; @@ -84,7 +137,7 @@ impl UserDAO { /// Set a user's ban status pub async fn set_ban_status( pool: &SqlitePool, - user_id: UserRowId, + user_id: UserDbId, is_banned: bool, ) -> Result<()> { sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") @@ -97,7 +150,7 @@ impl UserDAO { } /// Delete a user (soft delete by setting is_banned = true might be better in production) - pub async fn delete_user(pool: &SqlitePool, user_id: UserRowId) -> Result<()> { + pub async fn delete_user(pool: &SqlitePool, user_id: UserDbId) -> Result<()> { sqlx::query("DELETE FROM users WHERE id = ?") .bind(user_id) .execute(pool) @@ -105,27 +158,24 @@ impl UserDAO { Ok(()) } +} - /// Get or create a user (find by telegram_id, create if not found) - pub async fn get_or_create_user( - pool: &SqlitePool, - telegram_id: TelegramUserId, - username: Option, - display_name: Option, - ) -> Result { - // Try to find existing user first - if let Some(existing_user) = Self::find_by_telegram_id(pool, telegram_id).await? { - return Ok(existing_user); - } +impl FromRow<'_, SqliteRow> for PersistedUser { + fn from_row(row: &'_ SqliteRow) -> std::result::Result { + use sqlx::Row as _; - // Create new user if not found - let new_user = NewUser { - telegram_id, - username, - display_name, - }; - - Self::insert_user(pool, &new_user).await + Ok(PersistedUser { + persisted: PersistedUserFields { + id: row.get("id"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }, + username: row.get("username"), + first_name: row.get("first_name"), + last_name: row.get("last_name"), + is_banned: row.get("is_banned"), + telegram_id: row.get("telegram_id"), + }) } } @@ -133,7 +183,6 @@ impl UserDAO { mod tests { use super::*; use crate::db::models::user::NewUser; - use rstest::rstest; use sqlx::SqlitePool; use teloxide::types::UserId; @@ -157,9 +206,12 @@ mod tests { let pool = create_test_pool().await; let new_user = NewUser { + persisted: (), telegram_id: 12345.into(), + first_name: "Test User".to_string(), + last_name: None, username: Some("testuser".to_string()), - display_name: Some("Test User".to_string()), + is_banned: false, }; // Insert user @@ -169,16 +221,16 @@ mod tests { assert_eq!(inserted_user.telegram_id, 12345.into()); assert_eq!(inserted_user.username, Some("testuser".to_string())); - assert_eq!(inserted_user.display_name, Some("Test User".to_string())); + assert_eq!(inserted_user.first_name, "Test User".to_string()); assert_eq!(inserted_user.is_banned, false); // Find by ID - let found_user = UserDAO::find_by_id(&pool, inserted_user.id) + let found_user = UserDAO::find_by_id(&pool, inserted_user.persisted.id) .await .expect("Failed to find user by id") .expect("User should be found"); - assert_eq!(found_user.id, inserted_user.id); + assert_eq!(found_user.persisted.id, inserted_user.persisted.id); assert_eq!(found_user.telegram_id, inserted_user.telegram_id); // Find by telegram ID @@ -187,7 +239,7 @@ mod tests { .expect("Failed to find user by telegram_id") .expect("User should be found"); - assert_eq!(found_by_telegram.id, inserted_user.id); + assert_eq!(found_by_telegram.persisted.id, inserted_user.persisted.id); assert_eq!(found_by_telegram.telegram_id, 12345.into()); } @@ -196,11 +248,18 @@ mod tests { let pool = create_test_pool().await; // First call should create the user - let user1 = UserDAO::get_or_create_user( + let user1 = UserDAO::find_or_create_by_telegram_user( &pool, - 67890.into(), - Some("newuser".to_string()), - Some("New User".to_string()), + teloxide::types::User { + id: UserId(67890), + is_bot: false, + first_name: "New User".to_string(), + last_name: None, + username: Some("newuser".to_string()), + language_code: None, + is_premium: false, + added_to_attachment_menu: false, + }, ) .await .expect("Failed to get or create user"); @@ -209,58 +268,37 @@ mod tests { assert_eq!(user1.username, Some("newuser".to_string())); // Second call should return the same user - let user2 = UserDAO::get_or_create_user( + let user2 = UserDAO::find_or_create_by_telegram_user( &pool, - 67890.into(), - Some("differentusername".to_string()), // This should be ignored - Some("Different Name".to_string()), // This should be ignored + teloxide::types::User { + id: UserId(67890), + is_bot: false, + first_name: "New User".to_string(), + last_name: None, + username: Some("newuser".to_string()), + language_code: None, + is_premium: false, + added_to_attachment_menu: false, + }, ) .await .expect("Failed to get or create user"); - assert_eq!(user1.id, user2.id); + assert_eq!(user1.persisted.id, user2.persisted.id); assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved } - #[rstest] - #[case(true)] - #[case(false)] - #[tokio::test] - async fn test_ban_status_operations(#[case] is_banned: bool) { - let pool = create_test_pool().await; - - let new_user = NewUser { - telegram_id: 99999.into(), - username: Some("bantest".to_string()), - display_name: Some("Ban Test User".to_string()), - }; - - let user = UserDAO::insert_user(&pool, &new_user) - .await - .expect("Failed to insert user"); - - // Set ban status - UserDAO::set_ban_status(&pool, user.id, is_banned) - .await - .expect("Failed to set ban status"); - - // Verify ban status - let updated_user = UserDAO::find_by_id(&pool, user.id) - .await - .expect("Failed to find user") - .expect("User should exist"); - - assert_eq!(updated_user.is_banned, is_banned); - } - #[tokio::test] async fn test_update_user() { let pool = create_test_pool().await; let new_user = NewUser { + persisted: (), telegram_id: 55555.into(), username: Some("oldname".to_string()), - display_name: Some("Old Name".to_string()), + first_name: "Old Name".to_string(), + last_name: None, + is_banned: false, }; let mut user = UserDAO::insert_user(&pool, &new_user) @@ -269,7 +307,7 @@ mod tests { // Update user information user.username = Some("newname".to_string()); - user.display_name = Some("New Name".to_string()); + user.first_name = "New Name".to_string(); user.is_banned = true; let updated_user = UserDAO::update_user(&pool, &user) @@ -277,7 +315,7 @@ mod tests { .expect("Failed to update user"); assert_eq!(updated_user.username, Some("newname".to_string())); - assert_eq!(updated_user.display_name, Some("New Name".to_string())); + assert_eq!(updated_user.first_name, "New Name".to_string()); assert_eq!(updated_user.is_banned, true); } @@ -286,9 +324,12 @@ mod tests { let pool = create_test_pool().await; let new_user = NewUser { + persisted: (), telegram_id: 77777.into(), username: Some("deleteme".to_string()), - display_name: Some("Delete Me".to_string()), + first_name: "Delete Me".to_string(), + last_name: None, + is_banned: false, }; let user = UserDAO::insert_user(&pool, &new_user) @@ -296,12 +337,12 @@ mod tests { .expect("Failed to insert user"); // Delete user - UserDAO::delete_user(&pool, user.id) + UserDAO::delete_user(&pool, user.persisted.id) .await .expect("Failed to delete user"); // Verify user is gone - let not_found = UserDAO::find_by_id(&pool, user.id) + let not_found = UserDAO::find_by_id(&pool, user.persisted.id) .await .expect("Database operation should succeed"); @@ -313,7 +354,7 @@ mod tests { let pool = create_test_pool().await; // Try to find a user that doesn't exist - let not_found = UserDAO::find_by_id(&pool, UserRowId::new(99999)) + let not_found = UserDAO::find_by_id(&pool, UserDbId::new(99999)) .await .expect("Database operation should succeed"); diff --git a/src/db/mod.rs b/src/db/mod.rs index 8720608..3758cfe 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod bind_fields; pub mod dao; pub mod models; pub mod types; diff --git a/src/db/models/new_listing.rs b/src/db/models/draft_listing.rs similarity index 67% rename from src/db/models/new_listing.rs rename to src/db/models/draft_listing.rs index e06ce61..d519ae8 100644 --- a/src/db/models/new_listing.rs +++ b/src/db/models/draft_listing.rs @@ -1,27 +1,27 @@ -use crate::db::{ListingType, MoneyAmount, UserRowId}; +use crate::db::{Listing, ListingDbId, ListingType, MoneyAmount, UserDbId}; use chrono::{DateTime, Utc}; /// New listing data for insertion #[derive(Debug, Clone)] -pub struct NewListing { - pub base: NewListingBase, - pub fields: NewListingFields, +pub struct DraftListing { + pub base: DraftListingBase, + pub fields: DraftListingFields, } -impl NewListing { +impl DraftListing { pub fn listing_type(&self) -> ListingType { match &self.fields { - NewListingFields::BasicAuction { .. } => ListingType::BasicAuction, - NewListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction, - NewListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing, - NewListingFields::BlindAuction { .. } => ListingType::BlindAuction, + DraftListingFields::BasicAuction { .. } => ListingType::BasicAuction, + DraftListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction, + DraftListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing, + DraftListingFields::BlindAuction { .. } => ListingType::BlindAuction, } } } #[derive(Debug, Clone)] -pub struct NewListingBase { - pub seller_id: UserRowId, +pub struct DraftListingBase { + pub seller_id: UserDbId, pub title: String, pub description: Option, pub starts_at: DateTime, @@ -30,7 +30,7 @@ pub struct NewListingBase { #[derive(Debug, Clone)] #[allow(unused)] -pub enum NewListingFields { +pub enum DraftListingFields { BasicAuction { starting_bid: MoneyAmount, buy_now_price: Option, @@ -53,10 +53,18 @@ pub enum NewListingFields { }, } +impl From

for DraftListing { + fn from(listing: Listing) -> Self { + Self { + id: Some(listing.base.id), + } + } +} + #[allow(unused)] -impl NewListingBase { +impl DraftListingBase { pub fn new( - seller_id: UserRowId, + seller_id: UserDbId, title: String, description: Option, starts_at: DateTime, @@ -78,10 +86,10 @@ impl NewListingBase { buy_now_price: Option, min_increment: MoneyAmount, anti_snipe_minutes: Option, - ) -> NewListing { - NewListing { + ) -> DraftListing { + DraftListing { base: self, - fields: NewListingFields::BasicAuction { + fields: DraftListingFields::BasicAuction { starting_bid, buy_now_price, min_increment, @@ -98,10 +106,10 @@ impl NewListingBase { min_increment: Option, slots_available: i32, anti_snipe_minutes: i32, - ) -> NewListing { - NewListing { + ) -> DraftListing { + DraftListing { base: self, - fields: NewListingFields::MultiSlotAuction { + fields: DraftListingFields::MultiSlotAuction { starting_bid, buy_now_price, min_increment, @@ -116,10 +124,10 @@ impl NewListingBase { self, buy_now_price: MoneyAmount, slots_available: i32, - ) -> NewListing { - NewListing { + ) -> DraftListing { + DraftListing { base: self, - fields: NewListingFields::FixedPriceListing { + fields: DraftListingFields::FixedPriceListing { buy_now_price, slots_available, }, @@ -127,10 +135,10 @@ impl NewListingBase { } /// Create a new blind auction listing - pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> NewListing { - NewListing { + pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> DraftListing { + DraftListing { base: self, - fields: NewListingFields::BlindAuction { starting_bid }, + fields: DraftListingFields::BlindAuction { starting_bid }, } } } diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index 3df5274..c9dd770 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -9,33 +9,59 @@ //! The main `Listing` enum ensures that only valid fields are accessible for each type. //! Database mapping is handled through `ListingRow` with conversion traits. -use super::listing_type::ListingType; -use crate::db::{ListingId, MoneyAmount, UserRowId}; +use crate::db::{ListingDbId, ListingDuration, MoneyAmount, UserDbId}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; + +pub type NewListing = Listing; +pub type PersistedListing = Listing; + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +pub struct PersistedListingFields { + pub id: ListingDbId, + pub start_at: DateTime, + pub end_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)] +pub struct NewListingFields { + pub start_delay: ListingDuration, + pub end_delay: ListingDuration, +} /// Main listing/auction entity -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq, PartialEq)] #[allow(unused)] -pub struct Listing { +pub struct Listing { + pub persisted: P, pub base: ListingBase, pub fields: ListingFields, } /// Common fields shared by all listing types -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[allow(unused)] pub struct ListingBase { - pub id: ListingId, - pub seller_id: UserRowId, + pub seller_id: UserDbId, pub title: String, pub description: Option, - pub starts_at: DateTime, - pub ends_at: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, } -#[derive(Debug, Clone)] +impl ListingBase { + #[cfg(test)] + pub fn with_fields(self, fields: ListingFields) -> NewListing { + Listing { + persisted: NewListingFields::default(), + base: self, + fields, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[allow(unused)] pub enum ListingFields { BasicAuction { @@ -60,26 +86,11 @@ pub enum ListingFields { }, } -#[allow(unused)] -impl Listing { - /// Get the listing type as an enum value - pub fn listing_type(&self) -> ListingType { - match &self.fields { - ListingFields::BasicAuction { .. } => ListingType::BasicAuction, - ListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction, - ListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing, - ListingFields::BlindAuction { .. } => ListingType::BlindAuction, - } - } -} - #[cfg(test)] mod tests { - use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId}; - use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default}; - use super::*; - use chrono::{Duration, Utc}; + use crate::db::ListingDbId; + use crate::db::{ListingDAO, TelegramUserDbId}; use rstest::rstest; use sqlx::SqlitePool; @@ -108,290 +119,64 @@ mod tests { /// Create a test user using UserDAO and return their ID async fn create_test_user( pool: &SqlitePool, - telegram_id: TelegramUserId, + telegram_id: TelegramUserDbId, username: Option<&str>, - ) -> UserRowId { + ) -> UserDbId { use crate::db::{models::user::NewUser, UserDAO}; let new_user = NewUser { + persisted: (), telegram_id, + first_name: "Test User".to_string(), + last_name: None, username: username.map(|s| s.to_string()), - display_name: username.map(|s| s.to_string()), + is_banned: false, }; let user = UserDAO::insert_user(pool, &new_user) .await .expect("Failed to create test user"); - user.id - } - - /// Fetch a listing using ListingDAO by ID - async fn fetch_listing_using_dao(pool: &SqlitePool, id: ListingId) -> Listing { - use crate::db::ListingDAO; - - ListingDAO::find_by_id(pool, id) - .await - .expect("Failed to fetch listing using DAO") - .expect("Listing should exist") - } - - #[tokio::test] - async fn test_basic_auction_crud() { - let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 12345.into(), Some("testuser")).await; - - // Create a basic auction listing - let starts_at = Utc::now(); - let ends_at = starts_at + Duration::hours(24); - - let new_listing = build_base_listing( - seller_id, - "Test Basic Auction", - Some("A test auction for basic functionality"), - ) - .new_basic_auction( - MoneyAmount::from_str("10.00").unwrap(), - Some(MoneyAmount::from_str("100.00").unwrap()), - MoneyAmount::from_str("1.00").unwrap(), - Some(5), - ); - - // Insert using DAO - let actual_id = ListingDAO::insert_listing(&pool, &new_listing) - .await - .expect("Failed to insert listing") - .base - .id; - - // Fetch back from database using DAO - let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await; - - // Verify the round trip worked correctly - match reconstructed_listing.fields { - ListingFields::BasicAuction { - starting_bid, - buy_now_price, - min_increment, - anti_snipe_minutes, - } => { - assert_eq!(reconstructed_listing.base.seller_id, seller_id); - assert_eq!(reconstructed_listing.base.title, "Test Basic Auction"); - assert_eq!( - reconstructed_listing.base.description, - Some("A test auction for basic functionality".to_string()) - ); - assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap()); - assert_eq!( - buy_now_price, - Some(MoneyAmount::from_str("100.00").unwrap()) - ); - assert_eq!(min_increment, MoneyAmount::from_str("1.00").unwrap()); - assert_eq!(anti_snipe_minutes, Some(5)); - assert_timestamps_approx_eq_default!( - reconstructed_listing.base.starts_at, - starts_at - ); - assert_timestamps_approx_eq_default!(reconstructed_listing.base.ends_at, ends_at); - } - _ => panic!("Expected BasicAuction, got different variant"), - } + user.persisted.id } fn build_base_listing( - seller_id: UserRowId, - title: &str, + seller_id: UserDbId, + title: impl Into, description: Option<&str>, - ) -> NewListingBase { - NewListingBase { + ) -> ListingBase { + ListingBase { seller_id, - title: title.to_string(), + title: title.into(), description: description.map(|s| s.to_string()), - starts_at: Utc::now(), - ends_at: Utc::now() + Duration::hours(24), - } - } - - #[tokio::test] - async fn test_multi_slot_auction_crud() { - let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 67890.into(), Some("multislotuser")).await; - let listing = build_base_listing(seller_id, "Test Multi-Slot Auction", None) - .new_multi_slot_auction( - MoneyAmount::from_str("10.00").unwrap(), - MoneyAmount::from_str("50.00").unwrap(), - Some(MoneyAmount::from_str("2.50").unwrap()), - 5, - 10, - ); - - // Insert using DAO - let actual_id = ListingDAO::insert_listing(&pool, &listing) - .await - .expect("Failed to insert listing") - .base - .id; - - // Fetch back from database using DAO - let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await; - - // Verify the round trip worked correctly - match reconstructed_listing.fields { - ListingFields::MultiSlotAuction { - starting_bid, - buy_now_price, - min_increment, - slots_available, - anti_snipe_minutes, - } => { - let reconstructed_base = reconstructed_listing.base; - assert_eq!(reconstructed_base.seller_id, seller_id); - assert_eq!(reconstructed_base.title, "Test Multi-Slot Auction"); - assert_eq!(reconstructed_base.description, None); - assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap()); - assert_eq!(buy_now_price, MoneyAmount::from_str("50.00").unwrap()); - assert_eq!(min_increment, Some(MoneyAmount::from_str("2.50").unwrap())); - assert_eq!(slots_available, 5); - assert_eq!(anti_snipe_minutes, 10); - assert_listing_timestamps_approx_eq!(reconstructed_base, listing.base); - } - _ => panic!("Expected MultiSlotAuction, got different variant"), - } - } - - #[tokio::test] - async fn test_fixed_price_listing_crud() { - let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 11111.into(), Some("fixedpriceuser")).await; - - let listing = build_base_listing( - seller_id, - "Test Fixed Price Item", - Some("Fixed price sale with multiple slots"), - ) - .new_fixed_price_listing(MoneyAmount::from_str("25.99").unwrap(), 3); - - // Insert using DAO - let actual_id = ListingDAO::insert_listing(&pool, &listing) - .await - .expect("Failed to insert listing") - .base - .id; - - // Fetch back from database using DAO - let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await; - - // Verify the round trip worked correctly - match reconstructed_listing.fields { - ListingFields::FixedPriceListing { - buy_now_price, - slots_available, - } => { - assert_eq!(reconstructed_listing.base.seller_id, seller_id); - assert_eq!(reconstructed_listing.base.title, "Test Fixed Price Item"); - assert_eq!( - listing.base.description, - Some("Fixed price sale with multiple slots".to_string()) - ); - assert_eq!(buy_now_price, MoneyAmount::from_str("25.99").unwrap()); - assert_eq!(slots_available, 3); - assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base); - } - _ => panic!("Expected FixedPriceListing, got different variant"), - } - } - - #[tokio::test] - async fn test_blind_auction_crud() { - let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 99999.into(), Some("blinduser")).await; - - let listing = build_base_listing( - seller_id, - "Test Blind Auction", - Some("Seller chooses winner"), - ) - .new_blind_auction(MoneyAmount::from_str("100.00").unwrap()); - - // Insert using DAO - let actual_id = ListingDAO::insert_listing(&pool, &listing) - .await - .expect("Failed to insert listing") - .base - .id; - - // Fetch back from database using DAO - let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await; - - // Verify the round trip worked correctly - match reconstructed_listing.fields { - ListingFields::BlindAuction { starting_bid } => { - assert_eq!(reconstructed_listing.base.seller_id, seller_id); - assert_eq!(reconstructed_listing.base.title, "Test Blind Auction"); - assert_eq!( - reconstructed_listing.base.description, - Some("Seller chooses winner".to_string()) - ); - assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base); - assert_eq!(starting_bid, MoneyAmount::from_str("100.00").unwrap()); - } - _ => panic!("Expected BlindAuction, got different variant"), } } #[rstest] - #[case("10.50", "100.00", "1.00")] - #[case("0.00", "50.00", "0.25")] - #[case("25.75", "999.99", "5.50")] + #[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })] + #[case(ListingFields::BasicAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), min_increment: MoneyAmount::from_str("1.00").unwrap(), anti_snipe_minutes: Some(5) })] + #[case(ListingFields::MultiSlotAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: MoneyAmount::from_str("100.00").unwrap(), min_increment: Some(MoneyAmount::from_str("1.00").unwrap()), slots_available: 10, anti_snipe_minutes: 5 })] + #[case(ListingFields::FixedPriceListing { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 })] + #[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })] #[tokio::test] - async fn test_money_amount_precision_in_listings( - #[case] starting_bid_str: &str, - #[case] buy_now_price_str: &str, - #[case] min_increment_str: &str, - ) { + async fn test_blind_auction_crud(#[case] fields: ListingFields) { let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 55555.into(), Some("precisionuser")).await; - - let listing = build_base_listing(seller_id, "Precision Test Auction", None) - .new_basic_auction( - MoneyAmount::from_str(starting_bid_str).unwrap(), - Some(MoneyAmount::from_str(buy_now_price_str).unwrap()), - MoneyAmount::from_str(min_increment_str).unwrap(), - Some(5), - ); + let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await; + let new_listing = build_base_listing(seller_id, "Test Auction", Some("Test description")) + .with_fields(fields); // Insert using DAO - let actual_id = ListingDAO::insert_listing(&pool, &listing) + let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone()) .await - .expect("Failed to insert listing") - .base - .id; + .expect("Failed to insert listing"); - // Fetch back from database using DAO - let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await; + assert_eq!(created_listing.base, new_listing.base); + assert_eq!(created_listing.fields, new_listing.fields); - // Verify precision is maintained - match reconstructed_listing.fields { - ListingFields::BasicAuction { - starting_bid, - buy_now_price, - min_increment, - anti_snipe_minutes, - } => { - assert_eq!( - starting_bid, - MoneyAmount::from_str(starting_bid_str).unwrap() - ); - assert_eq!( - buy_now_price, - Some(MoneyAmount::from_str(buy_now_price_str).unwrap()) - ); - assert_eq!( - min_increment, - MoneyAmount::from_str(min_increment_str).unwrap(), - ); - assert_eq!(anti_snipe_minutes, Some(5)); - } - _ => panic!("Expected BasicAuction"), - } + let read_listing = ListingDAO::find_by_id(&pool, created_listing.persisted.id) + .await + .expect("Failed to find listing") + .expect("Listing should exist"); + + assert_eq!(read_listing, created_listing); } } diff --git a/src/db/models/listing_type.rs b/src/db/models/listing_type.rs index 504cbbf..a70fc85 100644 --- a/src/db/models/listing_type.rs +++ b/src/db/models/listing_type.rs @@ -1,5 +1,5 @@ /// Types of listings supported by the platform -#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)] +#[derive(Debug, Clone, PartialEq, Eq, Copy, sqlx::Type)] #[sqlx(type_name = "TEXT")] #[sqlx(rename_all = "snake_case")] pub enum ListingType { diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 4c08a36..af06988 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -2,12 +2,9 @@ pub mod bid; pub mod listing; pub mod listing_media; pub mod listing_type; -pub mod new_listing; pub mod proxy_bid; pub mod user; pub mod user_settings; // Re-export all types for easy access -pub use listing::*; pub use listing_type::*; -pub use user::*; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 6340f46..3f185b4 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,26 +1,27 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; +use std::fmt::Debug; -use crate::db::{TelegramUserId, UserRowId}; +use crate::db::{TelegramUserDbId, UserDbId}; + +pub type PersistedUser = User; +pub type NewUser = User<()>; /// Core user information #[derive(Debug, Clone, FromRow)] #[allow(unused)] -pub struct User { - pub id: UserRowId, - pub telegram_id: TelegramUserId, +pub struct User { + pub persisted: P, + pub telegram_id: TelegramUserDbId, + pub first_name: String, + pub last_name: Option, pub username: Option, - pub display_name: Option, - // SQLite stores booleans as INTEGER (0/1), sqlx FromRow handles the conversion automatically pub is_banned: bool, +} + +#[derive(Debug, Clone)] +pub struct PersistedUserFields { + pub id: UserDbId, pub created_at: DateTime, pub updated_at: DateTime, } - -/// New user data for insertion -#[derive(Debug, Clone)] -pub struct NewUser { - pub telegram_id: TelegramUserId, - pub username: Option, - pub display_name: Option, -} diff --git a/src/db/types/listing_id.rs b/src/db/types/db_id/listing_db_id.rs similarity index 82% rename from src/db/types/listing_id.rs rename to src/db/types/db_id/listing_db_id.rs index 4df3bca..d77fcf2 100644 --- a/src/db/types/listing_id.rs +++ b/src/db/types/db_id/listing_db_id.rs @@ -11,9 +11,9 @@ use std::fmt; /// Type-safe wrapper for listing IDs #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct ListingId(i64); +pub struct ListingDbId(i64); -impl ListingId { +impl ListingDbId { /// Create a new ListingId from an i64 pub fn new(id: i64) -> Self { Self(id) @@ -25,26 +25,26 @@ impl ListingId { } } -impl From for ListingId { +impl From for ListingDbId { fn from(id: i64) -> Self { Self(id) } } -impl From for i64 { - fn from(listing_id: ListingId) -> Self { +impl From for i64 { + fn from(listing_id: ListingDbId) -> Self { listing_id.0 } } -impl fmt::Display for ListingId { +impl fmt::Display for ListingDbId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } // SQLx implementations for database compatibility -impl Type for ListingId { +impl Type for ListingDbId { fn type_info() -> SqliteTypeInfo { >::type_info() } @@ -54,7 +54,7 @@ impl Type for ListingId { } } -impl<'q> Encode<'q, Sqlite> for ListingId { +impl<'q> Encode<'q, Sqlite> for ListingDbId { fn encode_by_ref( &self, args: &mut Vec>, @@ -63,7 +63,7 @@ impl<'q> Encode<'q, Sqlite> for ListingId { } } -impl<'r> Decode<'r, Sqlite> for ListingId { +impl<'r> Decode<'r, Sqlite> for ListingDbId { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { let id = >::decode(value)?; Ok(Self(id)) diff --git a/src/db/types/db_id/mod.rs b/src/db/types/db_id/mod.rs new file mode 100644 index 0000000..e99f651 --- /dev/null +++ b/src/db/types/db_id/mod.rs @@ -0,0 +1,7 @@ +mod listing_db_id; +mod telegram_user_db_id; +mod user_db_id; + +pub use listing_db_id::ListingDbId; +pub use telegram_user_db_id::TelegramUserDbId; +pub use user_db_id::UserDbId; diff --git a/src/db/types/telegram_user_id.rs b/src/db/types/db_id/telegram_user_db_id.rs similarity index 75% rename from src/db/types/telegram_user_id.rs rename to src/db/types/db_id/telegram_user_db_id.rs index f48d757..b90b1a5 100644 --- a/src/db/types/telegram_user_id.rs +++ b/src/db/types/db_id/telegram_user_db_id.rs @@ -10,10 +10,10 @@ use sqlx::{ use std::fmt; /// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct TelegramUserId(teloxide::types::UserId); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TelegramUserDbId(teloxide::types::UserId); -impl TelegramUserId { +impl TelegramUserDbId { /// Create a new TelegramUserId /// from an i64 pub fn new(id: teloxide::types::UserId) -> Self { @@ -26,32 +26,32 @@ impl TelegramUserId { } } -impl From for TelegramUserId { +impl From for TelegramUserDbId { fn from(id: teloxide::types::UserId) -> Self { Self(id) } } -impl From for TelegramUserId { +impl From for TelegramUserDbId { fn from(id: u64) -> Self { Self(teloxide::types::UserId(id)) } } -impl From for teloxide::types::UserId { - fn from(user_id: TelegramUserId) -> Self { +impl From for teloxide::types::UserId { + fn from(user_id: TelegramUserDbId) -> Self { user_id.0 } } -impl fmt::Display for TelegramUserId { +impl fmt::Display for TelegramUserDbId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } // SQLx implementations for database compatibility -impl Type for TelegramUserId { +impl Type for TelegramUserDbId { fn type_info() -> SqliteTypeInfo { >::type_info() } @@ -61,7 +61,7 @@ impl Type for TelegramUserId { } } -impl<'q> Encode<'q, Sqlite> for TelegramUserId { +impl<'q> Encode<'q, Sqlite> for TelegramUserDbId { fn encode_by_ref( &self, args: &mut Vec>, @@ -70,7 +70,7 @@ impl<'q> Encode<'q, Sqlite> for TelegramUserId { } } -impl<'r> Decode<'r, Sqlite> for TelegramUserId { +impl<'r> Decode<'r, Sqlite> for TelegramUserDbId { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { let id = >::decode(value)?; Ok(Self(teloxide::types::UserId(id as u64))) diff --git a/src/db/types/user_row_id.rs b/src/db/types/db_id/user_db_id.rs similarity index 77% rename from src/db/types/user_row_id.rs rename to src/db/types/db_id/user_db_id.rs index 226d799..8b3e471 100644 --- a/src/db/types/user_row_id.rs +++ b/src/db/types/db_id/user_db_id.rs @@ -3,16 +3,17 @@ //! This newtype prevents accidentally mixing up user IDs with other ID types //! while maintaining compatibility with the database layer through SQLx traits. +use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; use std::fmt; /// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct UserRowId(i64); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserDbId(i64); -impl UserRowId { +impl UserDbId { /// Create a new UserId from an i64 pub fn new(id: i64) -> Self { Self(id) @@ -24,26 +25,26 @@ impl UserRowId { } } -impl From for UserRowId { +impl From for UserDbId { fn from(id: i64) -> Self { Self(id) } } -impl From for i64 { - fn from(user_id: UserRowId) -> Self { +impl From for i64 { + fn from(user_id: UserDbId) -> Self { user_id.0 } } -impl fmt::Display for UserRowId { +impl fmt::Display for UserDbId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } // SQLx implementations for database compatibility -impl Type for UserRowId { +impl Type for UserDbId { fn type_info() -> SqliteTypeInfo { >::type_info() } @@ -53,7 +54,7 @@ impl Type for UserRowId { } } -impl<'q> Encode<'q, Sqlite> for UserRowId { +impl<'q> Encode<'q, Sqlite> for UserDbId { fn encode_by_ref( &self, args: &mut Vec>, @@ -62,7 +63,7 @@ impl<'q> Encode<'q, Sqlite> for UserRowId { } } -impl<'r> Decode<'r, Sqlite> for UserRowId { +impl<'r> Decode<'r, Sqlite> for UserDbId { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { let id = >::decode(value)?; Ok(Self(id)) diff --git a/src/db/types/mod.rs b/src/db/types/mod.rs index b59137b..2953ec9 100644 --- a/src/db/types/mod.rs +++ b/src/db/types/mod.rs @@ -1,15 +1,11 @@ mod currency_type; +mod db_id; mod listing_duration; -mod listing_id; mod money_amount; -mod telegram_user_id; -mod user_row_id; // Re-export all types for easy access #[allow(unused)] pub use currency_type::*; +pub use db_id::*; pub use listing_duration::*; -pub use listing_id::*; pub use money_amount::*; -pub use telegram_user_id::*; -pub use user_row_id::*; diff --git a/src/test_utils.rs b/src/test_utils.rs index 22e7bb6..3fc2cd9 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -87,56 +87,6 @@ macro_rules! assert_timestamps_approx_eq_default { }; } -/// Assert that the `starts_at` and `ends_at` fields of two structs are approximately equal. -/// -/// This macro is specifically designed for comparing listing timestamps where small -/// variations in timing are expected. Uses a default epsilon of 1 second. -/// -/// # Examples -/// -/// ``` -/// use chrono::Utc; -/// use crate::test_utils::assert_listing_timestamps_approx_eq; -/// -/// let original_listing = /* some listing */; -/// let reconstructed_listing = /* reconstructed from DB */; -/// -/// // Compare both starts_at and ends_at with default 1s epsilon -/// assert_listing_timestamps_approx_eq!( -/// original_listing.base, -/// reconstructed_listing.base -/// ); -/// ``` -#[macro_export] -macro_rules! assert_listing_timestamps_approx_eq { - ($left:expr, $right:expr) => { - $crate::assert_timestamps_approx_eq_default!( - $left.starts_at, - $right.starts_at, - "starts_at timestamps don't match" - ); - $crate::assert_timestamps_approx_eq_default!( - $left.ends_at, - $right.ends_at, - "ends_at timestamps don't match" - ); - }; - ($left:expr, $right:expr, $epsilon:expr) => { - $crate::assert_timestamps_approx_eq!( - $left.starts_at, - $right.starts_at, - $epsilon, - "starts_at timestamps don't match" - ); - $crate::assert_timestamps_approx_eq!( - $left.ends_at, - $right.ends_at, - $epsilon, - "ends_at timestamps don't match" - ); - }; -} - #[cfg(test)] mod tests { use chrono::{Duration, Utc};