major db refactors

This commit is contained in:
Dylan Knutson
2025-08-29 23:22:37 +00:00
parent c9acdc9fff
commit ff061cb3bf
21 changed files with 867 additions and 907 deletions

12
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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<MyListingsState> 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!(
"• <b>ID {}:</b> {}\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::<i64>()?);
let listing_id = ListingDbId::new(data.parse::<i64>()?);
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<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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,

View File

@@ -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: <b>${}</b>\n\n\
<i>Step 4 of 6: Available Slots</i>\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<MessageTarget>,
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<MessageTarget>,
state: ListingDraft,
draft: &ListingDraft,
keyboard: Option<InlineKeyboardMarkup>,
flash_message: Option<&str>,
) -> HandlerResult {
let description_text = state
.description
.as_deref()
.unwrap_or("<i>No description</i>");
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!(
"📋 <b>Listing Summary</b>\n\n\
<b>Title:</b> {}\n\
<b>Description:</b> {}\n\
<b>Price:</b> ${}\n\
<b>Available Slots:</b> {}\n\
<b>Start Time:</b> {}\n\
<b>Duration:</b> {}\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("📋 <i><b>Listing Summary</b></i>".to_string());
response_lines.push("".to_string());
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
response_lines.push(format!(
"📄 <b>Description:</b> {}",
draft
.base
.description
.as_deref()
.unwrap_or("<i>No description</i>")
));
send_message(
&bot,
target,
&response,
Some(ConfirmationKeyboardButtons::to_keyboard()),
)
.await?;
match &draft.fields {
ListingFields::FixedPriceListing { buy_now_price, .. } => {
response_lines.push(format!("💰 <b>Buy it Now Price:</b> ${}", buy_now_price));
}
_ => {}
}
match &draft.persisted {
ListingDraftPersisted::New(fields) => {
response_lines.push(format!("<b>Start delay:</b> {}", fields.start_delay));
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
}
ListingDraftPersisted::Persisted(fields) => {
response_lines.push(format!("<b>Starts on:</b> {}", fields.start_at));
response_lines.push(format!("<b>Ends on:</b> {}", 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<MessageTarget>,
state: ListingDraft,
draft: &ListingDraft,
flash_message: Option<&str>,
) -> HandlerResult {
let target = target.into();
let description_text = state
.description
.as_deref()
.unwrap_or("<i>No description</i>");
let start_time_str = format!("In {}", state.start_delay);
let mut response = format!(
"✏️ <b>Editing Listing:</b>\n\n\
📝 <b>Title:</b> {}\n\
📄 <b>Description:</b> {}\n\
💰 <b>Price:</b> ${}\n\
🔢 <b>Available Slots:</b> {}\n\
⏰ <b>Start Time:</b> {}\n\
⏳ <b>Duration:</b> {}\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<MessageTarget>,
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<MessageTarget>,
draft: ListingDraft,
) -> HandlerResult {
let now = Utc::now();
let starts_at = now + Into::<Duration>::into(draft.start_delay);
let ends_at = starts_at + Into::<Duration>::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!(
"✅ <b>Listing Created Successfully!</b>\n\n\
let response = format!(
"✅ <b>Listing Created Successfully!</b>\n\n\
<b>Listing ID:</b> {}\n\
<b>Title:</b> {}\n\
<b>Price:</b> ${}\n\
<b>Slots Available:</b> {}\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),
"❌ <b>Error:</b> Failed to create listing. Please try again later.",
None,
)
.await?;
}
}
Ok(())
}
async fn cancel_wizard(
bot: Bot,
dialogue: RootDialogue,
target: impl Into<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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<MessageTarget>,
) -> 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<MessageTarget>,
) -> HandlerResult {
let target = target.into();
info!("{target:?} cancelled new listing wizard");
dialogue.exit().await?;
send_message(&bot, target, "❌ Listing creation cancelled.", None).await?;
Ok(())
}

View File

@@ -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<String>,
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)]

64
src/db/bind_fields.rs Normal file
View File

@@ -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<T>(value: T) -> BindFn
where
T: for<'q> Encode<'q, Sqlite> + Type<Sqlite> + 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<Sqlite> + 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<Item = &'static str> + '_ {
self.binds.iter().map(|(name, _)| *name)
}
pub fn bind_placeholders(&self) -> impl Iterator<Item = &'static str> + '_ {
repeat("?").take(self.binds.len())
}
}

View File

@@ -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<Listing> {
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<PersistedListing> {
let now = Utc::now();
let start_at = now + Into::<Duration>::into(listing.persisted.start_delay);
let end_at = start_at + Into::<Duration>::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<PersistedListing> {
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<Option<Listing>> {
let result = sqlx::query("SELECT * FROM listings WHERE id = ?")
pub async fn find_by_id(
pool: &SqlitePool,
listing_id: ListingDbId,
) -> Result<Option<PersistedListing>> {
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<Vec<Listing>> {
pub async fn find_by_seller(
pool: &SqlitePool,
seller_id: UserDbId,
) -> Result<Vec<PersistedListing>> {
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::<Result<Vec<_>>>()
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<Listing> {
fn binds_for_listing<P: Debug + Clone>(listing: &Listing<P>) -> 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<Self, sqlx::Error> {
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,
})
}
}

View File

@@ -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<User> {
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<PersistedUser> {
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<Option<User>> {
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<Option<PersistedUser>> {
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<TelegramUserId>,
) -> Result<Option<User>> {
telegram_id: impl Into<TelegramUserDbId>,
) -> Result<Option<PersistedUser>> {
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<PersistedUser> {
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<User> {
let updated_user = sqlx::query_as::<_, User>(
pub async fn update_user(pool: &SqlitePool, user: &PersistedUser) -> Result<PersistedUser> {
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<String>,
display_name: Option<String>,
) -> Result<User> {
// 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<Self, sqlx::Error> {
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");

View File

@@ -1,3 +1,4 @@
pub mod bind_fields;
pub mod dao;
pub mod models;
pub mod types;

View File

@@ -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<String>,
pub starts_at: DateTime<Utc>,
@@ -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<MoneyAmount>,
@@ -53,10 +53,18 @@ pub enum NewListingFields {
},
}
impl From<Listing> 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<String>,
starts_at: DateTime<Utc>,
@@ -78,10 +86,10 @@ impl NewListingBase {
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
anti_snipe_minutes: Option<i32>,
) -> 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<MoneyAmount>,
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 },
}
}
}

View File

@@ -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<NewListingFields>;
pub type PersistedListing = Listing<PersistedListingFields>;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields {
pub id: ListingDbId,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<P: Debug + Clone> {
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<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
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);
}
}

View File

@@ -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 {

View File

@@ -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::*;

View File

@@ -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<PersistedUserFields>;
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<P: Debug + Clone> {
pub persisted: P,
pub telegram_id: TelegramUserDbId,
pub first_name: String,
pub last_name: Option<String>,
pub username: Option<String>,
pub display_name: Option<String>,
// 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<Utc>,
pub updated_at: DateTime<Utc>,
}
/// New user data for insertion
#[derive(Debug, Clone)]
pub struct NewUser {
pub telegram_id: TelegramUserId,
pub username: Option<String>,
pub display_name: Option<String>,
}

View File

@@ -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<i64> for ListingId {
impl From<i64> for ListingDbId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<ListingId> for i64 {
fn from(listing_id: ListingId) -> Self {
impl From<ListingDbId> 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<Sqlite> for ListingId {
impl Type<Sqlite> for ListingDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
@@ -54,7 +54,7 @@ impl Type<Sqlite> for ListingId {
}
}
impl<'q> Encode<'q, Sqlite> for ListingId {
impl<'q> Encode<'q, Sqlite> for ListingDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
@@ -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<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))

View File

@@ -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;

View File

@@ -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<teloxide::types::UserId> for TelegramUserId {
impl From<teloxide::types::UserId> for TelegramUserDbId {
fn from(id: teloxide::types::UserId) -> Self {
Self(id)
}
}
impl From<u64> for TelegramUserId {
impl From<u64> for TelegramUserDbId {
fn from(id: u64) -> Self {
Self(teloxide::types::UserId(id))
}
}
impl From<TelegramUserId> for teloxide::types::UserId {
fn from(user_id: TelegramUserId) -> Self {
impl From<TelegramUserDbId> 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<Sqlite> for TelegramUserId {
impl Type<Sqlite> for TelegramUserDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
@@ -61,7 +61,7 @@ impl Type<Sqlite> for TelegramUserId {
}
}
impl<'q> Encode<'q, Sqlite> for TelegramUserId {
impl<'q> Encode<'q, Sqlite> for TelegramUserDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
@@ -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<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(teloxide::types::UserId(id as u64)))

View File

@@ -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<i64> for UserRowId {
impl From<i64> for UserDbId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<UserRowId> for i64 {
fn from(user_id: UserRowId) -> Self {
impl From<UserDbId> 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<Sqlite> for UserRowId {
impl Type<Sqlite> for UserDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
@@ -53,7 +54,7 @@ impl Type<Sqlite> for UserRowId {
}
}
impl<'q> Encode<'q, Sqlite> for UserRowId {
impl<'q> Encode<'q, Sqlite> for UserDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
@@ -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<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))

View File

@@ -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::*;

View File

@@ -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};