basic listing stuff

This commit is contained in:
Dylan Knutson
2025-09-02 01:43:15 +00:00
parent da1fd1027f
commit f74c3502d6
21 changed files with 739 additions and 623 deletions

15
Cargo.lock generated
View File

@@ -1604,6 +1604,12 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pawctioneer-bot" name = "pawctioneer-bot"
version = "0.1.0" version = "0.1.0"
@@ -1612,15 +1618,18 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"dotenvy", "dotenvy",
"dptree",
"env_logger", "env_logger",
"futures", "futures",
"itertools 0.14.0", "itertools 0.14.0",
"lazy_static", "lazy_static",
"log", "log",
"num", "num",
"paste",
"regex", "regex",
"rstest", "rstest",
"rust_decimal", "rust_decimal",
"seq-macro",
"serde", "serde",
"sqlx", "sqlx",
"teloxide", "teloxide",
@@ -2248,6 +2257,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "seq-macro"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.219" version = "1.0.219"

View File

@@ -28,6 +28,9 @@ num = "0.4.3"
itertools = "0.14.0" itertools = "0.14.0"
async-trait = "0.1" async-trait = "0.1"
regex = "1.11.2" regex = "1.11.2"
paste = "1.0"
dptree = "0.5.1"
seq-macro = "0.3.6"
[dev-dependencies] [dev-dependencies]
rstest = "0.26.1" rstest = "0.26.1"

View File

@@ -23,6 +23,7 @@ CREATE TABLE listings (
listing_type TEXT NOT NULL, -- 'basic_auction', 'multi_slot_auction', 'fixed_price_listing', 'blind_auction' listing_type TEXT NOT NULL, -- 'basic_auction', 'multi_slot_auction', 'fixed_price_listing', 'blind_auction'
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
currency_type TEXT NOT NULL, -- 'usd'
-- Pricing (stored as INTEGER cents for USD) -- Pricing (stored as INTEGER cents for USD)
starting_bid INTEGER, starting_bid INTEGER,

View File

@@ -16,22 +16,20 @@ use teloxide::types::InlineKeyboardButton;
pub enum MyListingsButtons { pub enum MyListingsButtons {
SelectListing(ListingDbId), SelectListing(ListingDbId),
NewListing, NewListing,
BackToMenu,
} }
impl MyListingsButtons { impl MyListingsButtons {
pub fn listing_into_button(listing: &PersistedListing) -> InlineKeyboardButton { pub fn listing_into_button(listing: &PersistedListing) -> InlineKeyboardButton {
InlineKeyboardButton::callback( let text = format!(
&listing.base.title, "{} {} - {}",
Self::encode_listing_id(listing.persisted.id), listing.fields.listing_type().emoji_str(),
) listing.fields.listing_type(),
} listing.base.title,
);
pub fn back_to_menu_into_button() -> InlineKeyboardButton { InlineKeyboardButton::callback(text, Self::encode_listing_id(listing.persisted.id))
InlineKeyboardButton::callback("Back to Menu", "my_listings_back_to_menu")
} }
pub fn new_listing_into_button() -> InlineKeyboardButton { pub fn new_listing_into_button() -> InlineKeyboardButton {
InlineKeyboardButton::callback("🛍️ New Listing", "my_listings_new_listing") InlineKeyboardButton::callback(" New Listing", "my_listings_new_listing")
} }
fn encode_listing_id(listing_id: ListingDbId) -> String { fn encode_listing_id(listing_id: ListingDbId) -> String {
@@ -54,7 +52,6 @@ impl TryFrom<&str> for MyListingsButtons {
} }
match value { match value {
"my_listings_new_listing" => Ok(MyListingsButtons::NewListing), "my_listings_new_listing" => Ok(MyListingsButtons::NewListing),
"my_listings_back_to_menu" => Ok(MyListingsButtons::BackToMenu),
_ => anyhow::bail!("Unknown MyListingsButtons: {value}"), _ => anyhow::bail!("Unknown MyListingsButtons: {value}"),
} }
} }

View File

@@ -5,15 +5,19 @@ use crate::{
commands::{ commands::{
enter_main_menu, enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons}, my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, enter_select_new_listing_type, ListingDraft}, new_listing::{
enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons,
ListingDraft,
},
}, },
db::{ db::{
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDAO, ListingDbId, ListingDAO, ListingDbId, ListingType,
}, },
handler_utils::{ handler_utils::{
callback_query_into_message_target, find_or_create_db_user_from_callback_query, find_or_create_db_user_from_message, message_into_message_target callback_query_into_message_target, find_or_create_db_user_from_callback_query,
find_or_create_db_user_from_message, message_into_message_target,
}, },
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue, Command, DialogueRootState, HandlerResult, RootDialogue,
@@ -146,7 +150,7 @@ async fn handle_forward_listing(
.as_deref() .as_deref()
.unwrap_or("No description"), .unwrap_or("No description"),
current_price, current_price,
listing.persisted.end_at.format("%b %d, %Y at %H:%M UTC") listing.base.ends_at.format("%b %d, %Y at %H:%M UTC")
); );
bot.answer_inline_query( bot.answer_inline_query(
@@ -221,7 +225,7 @@ pub async fn enter_my_listings(
} }
keyboard = keyboard.append_row(vec![ keyboard = keyboard.append_row(vec![
MyListingsButtons::new_listing_into_button(), MyListingsButtons::new_listing_into_button(),
MyListingsButtons::back_to_menu_into_button(), NavKeyboardButtons::Back.to_button(),
]); ]);
if listings.is_empty() { if listings.is_empty() {
@@ -261,9 +265,12 @@ async fn handle_viewing_listings_callback(
) -> HandlerResult { ) -> HandlerResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&bot, callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(bot, dialogue, target).await;
}
// Check if it's the back to menu button // Check if it's the back to menu button
let button = MyListingsButtons::try_from(data.as_str())?; let button = MyListingsButtons::try_from(data.as_str())?;
match button { match button {
MyListingsButtons::SelectListing(listing_id) => { MyListingsButtons::SelectListing(listing_id) => {
let listing = let listing =
@@ -274,10 +281,6 @@ async fn handle_viewing_listings_callback(
MyListingsButtons::NewListing => { MyListingsButtons::NewListing => {
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(bot, dialogue, target).await?;
} }
MyListingsButtons::BackToMenu => {
// Transition back to main menu using the reusable function
enter_main_menu(bot, dialogue, target).await?
}
} }
Ok(()) Ok(())
@@ -289,8 +292,10 @@ async fn enter_show_listing_details(
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget, target: MessageTarget,
) -> HandlerResult { ) -> HandlerResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
let listing_id = listing.persisted.id;
let response = format!( let response = format!(
"🔍 <b>Listing Details</b>\n\n\ "🔍 <b>{listing_type} Details</b>\n\n\
<b>Title:</b> {}\n\ <b>Title:</b> {}\n\
<b>Description:</b> {}\n", <b>Description:</b> {}\n",
listing.base.title, listing.base.title,
@@ -301,29 +306,22 @@ async fn enter_show_listing_details(
.unwrap_or("No description"), .unwrap_or("No description"),
); );
dialogue dialogue
.update(MyListingsState::ManagingListing(listing.persisted.id)) .update(MyListingsState::ManagingListing(listing_id))
.await?; .await?;
send_message( let keyboard = InlineKeyboardMarkup::default()
bot, .append_row([
target, ManageListingButtons::PreviewMessage.to_button(),
response, InlineKeyboardButton::switch_inline_query(
Some( ManageListingButtons::ForwardListing.title(),
InlineKeyboardMarkup::default() format!("forward_listing:{listing_id}"),
.append_row([ ),
ManageListingButtons::PreviewMessage.to_button(), ])
InlineKeyboardButton::switch_inline_query( .append_row([
ManageListingButtons::ForwardListing.title(), ManageListingButtons::Edit.to_button(),
format!("forward_listing:{}", listing.persisted.id), ManageListingButtons::Delete.to_button(),
), ])
]) .append_row([ManageListingButtons::Back.to_button()]);
.append_row([ send_message(bot, target, response, Some(keyboard)).await?;
ManageListingButtons::Edit.to_button(),
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]),
),
)
.await?;
Ok(()) Ok(())
} }
@@ -357,7 +355,15 @@ async fn handle_managing_listing_callback(
} }
ManageListingButtons::Delete => { ManageListingButtons::Delete => {
ListingDAO::delete_listing(&db_pool, listing_id).await?; ListingDAO::delete_listing(&db_pool, listing_id).await?;
send_message(&bot, target, "Listing deleted.", None).await?; enter_my_listings(
db_pool,
bot,
dialogue,
user,
target,
Some("Listing deleted.".to_string()),
)
.await?;
} }
ManageListingButtons::Back => { ManageListingButtons::Back => {
enter_my_listings(db_pool, bot, dialogue, user, target, None).await?; enter_my_listings(db_pool, bot, dialogue, user, target, None).await?;

View File

@@ -5,25 +5,27 @@
use crate::{ use crate::{
commands::{ commands::{
my_listings::enter_my_listings,
new_listing::{ new_listing::{
enter_select_new_listing_type, enter_select_new_listing_type,
field_processing::transition_to_field, field_processing::{transition_to_field, update_field_on_draft},
keyboard::*, keyboard::*,
messages::*, messages::*,
types::{ListingDraft, ListingDraftPersisted, ListingField}, types::{ListingDraft, ListingField},
ui::enter_confirm_save_listing, ui::enter_confirm_save_listing,
}, },
start::enter_main_menu,
}, },
db::{listing::ListingFields, user::PersistedUser, ListingDuration, ListingType, MoneyAmount}, db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
message_utils::*, message_utils::*,
HandlerResult, RootDialogue, HandlerResult, RootDialogue,
}; };
use log::{error, info}; use log::{error, info};
use sqlx::SqlitePool;
use teloxide::{types::CallbackQuery, Bot}; use teloxide::{types::CallbackQuery, Bot};
/// Handle callbacks during the listing type selection phase /// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback( pub async fn handle_selecting_listing_type_callback(
db_pool: SqlitePool,
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
@@ -34,7 +36,7 @@ pub async fn handle_selecting_listing_type_callback(
info!("User {target:?} selected listing type: {data:?}"); info!("User {target:?} selected listing type: {data:?}");
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(bot, dialogue, target).await; return enter_my_listings(db_pool, bot, dialogue, user, target, None).await;
} }
// Parse the listing type from callback data // Parse the listing type from callback data
@@ -107,17 +109,20 @@ pub async fn handle_awaiting_draft_field_callback(
let button = StartTimeKeyboardButtons::try_from(data.as_str())?; let button = StartTimeKeyboardButtons::try_from(data.as_str())?;
handle_start_time_callback(&bot, dialogue, draft, button, target).await handle_start_time_callback(&bot, dialogue, draft, button, target).await
} }
ListingField::Duration => { ListingField::EndTime => {
let button = DurationKeyboardButtons::try_from(data.as_str())?; let button = DurationKeyboardButtons::try_from(data.as_str())?;
handle_duration_callback(&bot, dialogue, draft, button, target).await handle_duration_callback(&bot, dialogue, draft, button, target).await
} }
ListingField::StartingBidAmount => { ListingField::MinBidIncrement => {
let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?; let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?;
handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await
} }
ListingField::CurrencyType => {
let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?;
handle_currency_type_callback(&bot, dialogue, draft, button, target).await
}
_ => { _ => {
error!("Unknown callback data for field {field:?}: {data}"); error!("Unknown callback data for field {field:?}: {data}");
dialogue.exit().await?;
Ok(()) Ok(())
} }
} }
@@ -162,12 +167,12 @@ async fn handle_slots_callback(
SlotsKeyboardButtons::TenSlots => 10, SlotsKeyboardButtons::TenSlots => 10,
}; };
match &mut draft.fields { update_field_on_draft(
ListingFields::FixedPriceListing(fields) => { ListingField::Slots,
fields.slots_available = num_slots; &mut draft,
} Some(num_slots.to_string().as_str()),
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"), )
} .map_err(|e| anyhow::anyhow!("Error updating slots: {e:?}"))?;
let response = format!( let response = format!(
"✅ Available slots: <b>{num_slots}</b>\n\n{}", "✅ Available slots: <b>{num_slots}</b>\n\n{}",
@@ -196,25 +201,24 @@ async fn handle_start_time_callback(
StartTimeKeyboardButtons::Now => ListingDuration::zero(), StartTimeKeyboardButtons::Now => ListingDuration::zero(),
}; };
match &mut draft.persisted { update_field_on_draft(
ListingDraftPersisted::New(fields) => { ListingField::StartTime,
fields.start_delay = start_time; &mut draft,
} Some(start_time.to_string().as_str()),
ListingDraftPersisted::Persisted(_) => { )
anyhow::bail!("Cannot update start time for persisted listing"); .map_err(|e| anyhow::anyhow!("Error updating start time: {e:?}"))?;
}
}
let response = format!( let response = format!(
"✅ Listing will start: <b>immediately</b>\n\n{}", "✅ Listing will start: <b>{}</b>\n\n{}",
get_step_message(ListingField::Duration, draft.listing_type()) start_time,
get_step_message(ListingField::EndTime, draft.listing_type())
); );
transition_to_field(dialogue, ListingField::Duration, draft).await?; transition_to_field(dialogue, ListingField::EndTime, draft).await?;
send_message( send_message(
bot, bot,
target, target,
&response, &response,
get_keyboard_for_field(ListingField::Duration), get_keyboard_for_field(ListingField::EndTime),
) )
.await?; .await?;
Ok(()) Ok(())
@@ -235,16 +239,14 @@ async fn handle_duration_callback(
DurationKeyboardButtons::FourteenDays => 14, DurationKeyboardButtons::FourteenDays => 14,
}); });
match &mut draft.persisted { update_field_on_draft(
ListingDraftPersisted::New(fields) => { ListingField::EndTime,
fields.end_delay = duration; &mut draft,
} Some(duration.to_string().as_str()),
ListingDraftPersisted::Persisted(_) => { )
anyhow::bail!("Cannot update duration for persisted listing"); .map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?;
}
}
let flash = get_success_message(ListingField::Duration, draft.listing_type()); let flash = get_success_message(ListingField::EndTime, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await
} }
@@ -261,17 +263,49 @@ async fn handle_starting_bid_amount_callback(
EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00", EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00",
})?; })?;
match &mut draft.fields { update_field_on_draft(
ListingFields::BasicAuction(fields) => { ListingField::StartingBidAmount,
fields.starting_bid = starting_bid_amount; &mut draft,
} Some(starting_bid_amount.to_string().as_str()),
_ => anyhow::bail!("Cannot update starting bid amount for non-basic auction listing"), )
} .map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?;
let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type()); let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await
} }
async fn handle_currency_type_callback(
bot: &Bot,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: CurrencyTypeKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
let currency_type = match button {
CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd,
CurrencyTypeKeyboardButtons::Cad => CurrencyType::Cad,
CurrencyTypeKeyboardButtons::Gbp => CurrencyType::Gbp,
CurrencyTypeKeyboardButtons::Eur => CurrencyType::Eur,
};
update_field_on_draft(
ListingField::CurrencyType,
&mut draft,
Some(currency_type.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating currency type: {e:?}"))?;
let next_field = ListingField::StartingBidAmount;
let response = format!(
"✅ Listing will use currency: <b>{}</b>\n\n{}",
currency_type,
get_step_message(next_field, draft.listing_type())
);
transition_to_field(dialogue, next_field, draft).await?;
send_message(bot, target, &response, get_keyboard_for_field(next_field)).await?;
Ok(())
}
/// Cancel the wizard and exit /// Cancel the wizard and exit
pub async fn cancel_wizard( pub async fn cancel_wizard(
bot: Bot, bot: Bot,

View File

@@ -3,10 +3,10 @@
//! This module handles the core logic for processing and updating listing fields //! This module handles the core logic for processing and updating listing fields
//! during both initial creation and editing workflows. //! during both initial creation and editing workflows.
use crate::commands::new_listing::messages::step_for_field;
use crate::commands::new_listing::{types::NewListingState, validations::*}; use crate::commands::new_listing::{types::NewListingState, validations::*};
use crate::{ use crate::{
commands::new_listing::types::{ListingDraft, ListingDraftPersisted, ListingField}, commands::new_listing::types::{ListingDraft, ListingField},
db::listing::ListingFields,
HandlerResult, RootDialogue, HandlerResult, RootDialogue,
}; };
@@ -22,84 +22,15 @@ pub async fn transition_to_field(
Ok(()) Ok(())
} }
#[derive(Debug, Clone)]
pub enum UpdateFieldError {
ValidationError(String),
UnsupportedFieldType(ListingField),
FrozenField(ListingField),
}
/// Process field input and update the draft /// Process field input and update the draft
pub fn update_field_on_draft( pub fn update_field_on_draft(
field: ListingField, field: ListingField,
draft: &mut ListingDraft, draft: &mut ListingDraft,
text: &str, text: Option<&str>,
) -> Result<(), UpdateFieldError> { ) -> Result<(), SetFieldError> {
match field { let step = step_for_field(field, draft.listing_type())
ListingField::Title => { .ok_or(SetFieldError::UnsupportedFieldForListingType)?;
draft.base.title = validate_title(text).map_err(UpdateFieldError::ValidationError)?; (step.set_field_value)(draft, text.map(|s| s.trim().to_string()))?;
} draft.has_changes = true;
ListingField::Description => {
draft.base.description =
Some(validate_description(text).map_err(UpdateFieldError::ValidationError)?);
}
ListingField::Price => match &mut draft.fields {
ListingFields::FixedPriceListing(fields) => {
fields.buy_now_price =
validate_price(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
ListingField::Slots => match &mut draft.fields {
ListingFields::FixedPriceListing(fields) => {
fields.slots_available =
validate_slots(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
ListingField::StartTime => match &mut draft.persisted {
ListingDraftPersisted::New(fields) => {
fields.start_delay =
validate_start_time(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::FrozenField(field)),
},
ListingField::Duration => match &mut draft.persisted {
ListingDraftPersisted::New(fields) => {
fields.end_delay =
validate_duration(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::FrozenField(field)),
},
ListingField::StartingBidAmount => match &mut draft.fields {
ListingFields::BasicAuction(fields) => {
fields.starting_bid =
validate_price(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
ListingField::BuyNowPrice => match &mut draft.fields {
ListingFields::BasicAuction(fields) => {
fields.buy_now_price =
Some(validate_price(text).map_err(UpdateFieldError::ValidationError)?);
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
ListingField::MinBidIncrement => match &mut draft.fields {
ListingFields::BasicAuction(fields) => {
fields.min_increment =
validate_price(text).map_err(UpdateFieldError::ValidationError)?;
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
ListingField::AntiSnipeMinutes => match &mut draft.fields {
ListingFields::BasicAuction(fields) => {
fields.anti_snipe_minutes = Some(
validate_anti_snipe_minutes(text).map_err(UpdateFieldError::ValidationError)?,
);
}
_ => return Err(UpdateFieldError::UnsupportedFieldType(field)),
},
};
Ok(()) Ok(())
} }

View File

@@ -7,8 +7,7 @@ use crate::{
commands::{ commands::{
my_listings::enter_my_listings, my_listings::enter_my_listings,
new_listing::{ new_listing::{
callbacks::cancel_wizard, field_processing::{transition_to_field, update_field_on_draft},
field_processing::{transition_to_field, update_field_on_draft, UpdateFieldError},
keyboard::{ keyboard::{
ConfirmationKeyboardButtons, DurationKeyboardButtons, ConfirmationKeyboardButtons, DurationKeyboardButtons,
FieldSelectionKeyboardButtons, SlotsKeyboardButtons, StartTimeKeyboardButtons, FieldSelectionKeyboardButtons, SlotsKeyboardButtons, StartTimeKeyboardButtons,
@@ -16,14 +15,15 @@ use crate::{
messages::{ messages::{
get_edit_success_message, get_keyboard_for_field, get_listing_type_keyboard, get_edit_success_message, get_keyboard_for_field, get_listing_type_keyboard,
get_listing_type_selection_message, get_next_field, get_step_message, get_listing_type_selection_message, get_next_field, get_step_message,
get_success_message, get_success_message, step_for_field,
}, },
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState}, types::{ListingDraft, ListingField, NewListingState},
ui::{display_listing_summary, enter_confirm_save_listing}, ui::{display_listing_summary, enter_confirm_save_listing},
validations::SetFieldError,
}, },
}, },
db::{ db::{
listing::{ListingFields, NewListing, PersistedListing}, listing::{NewListing, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDAO, ListingDAO,
}, },
@@ -73,25 +73,19 @@ pub async fn handle_awaiting_draft_field_input(
target: MessageTarget, target: MessageTarget,
msg: Message, msg: Message,
) -> HandlerResult { ) -> HandlerResult {
let text = msg.text().unwrap_or("");
info!("User {target:?} entered input step: {field:?}"); info!("User {target:?} entered input step: {field:?}");
if is_cancel(text) {
return cancel_wizard(bot, dialogue, target).await;
}
// Process the field update // Process the field update
match update_field_on_draft(field, &mut draft, text) { match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (), Ok(()) => (),
Err(UpdateFieldError::ValidationError(e)) => { Err(SetFieldError::ValidationFailed(e)) => {
send_message(&bot, target, e.clone(), None).await?; send_message(&bot, target, e.clone(), None).await?;
return Ok(()); return Ok(());
} }
Err(UpdateFieldError::UnsupportedFieldType(field)) => { Err(SetFieldError::UnsupportedFieldForListingType) => {
bail!("Cannot update field {field:?} for listing type"); bail!("Cannot update field {field:?} for listing type");
} }
Err(UpdateFieldError::FrozenField(field)) => { Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field {field:?} on existing listing"); bail!("Cannot update field {field:?} on existing listing");
} }
}; };
@@ -120,26 +114,23 @@ pub async fn handle_editing_field_input(
target: MessageTarget, target: MessageTarget,
msg: Message, msg: Message,
) -> HandlerResult { ) -> HandlerResult {
let text = msg.text().unwrap_or("").trim();
info!("User {target:?} editing field {field:?}"); info!("User {target:?} editing field {field:?}");
// Process the field update // Process the field update
match update_field_on_draft(field, &mut draft, text) { match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (), Ok(()) => (),
Err(UpdateFieldError::ValidationError(e)) => { Err(SetFieldError::ValidationFailed(e)) => {
send_message(&bot, target, e.clone(), None).await?; send_message(&bot, target, e.clone(), None).await?;
return Ok(()); return Ok(());
} }
Err(UpdateFieldError::UnsupportedFieldType(_)) => { Err(SetFieldError::UnsupportedFieldForListingType) => {
bail!("Cannot update field for listing type"); bail!("Cannot update field {field:?} for listing type");
} }
Err(UpdateFieldError::FrozenField(_)) => { Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field on existing listing"); bail!("Cannot update field {field:?} on existing listing");
} }
}; };
draft.has_changes = true;
let flash = get_edit_success_message(field, draft.listing_type()); let flash = get_edit_success_message(field, draft.listing_type());
enter_edit_listing_draft(&bot, target, draft, dialogue, Some(flash)).await?; enter_edit_listing_draft(&bot, target, draft, dialogue, Some(flash)).await?;
Ok(()) Ok(())
@@ -210,7 +201,7 @@ pub async fn handle_editing_draft_callback(
FieldSelectionKeyboardButtons::Price => ListingField::Price, FieldSelectionKeyboardButtons::Price => ListingField::Price,
FieldSelectionKeyboardButtons::Slots => ListingField::Slots, FieldSelectionKeyboardButtons::Slots => ListingField::Slots,
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime, FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
FieldSelectionKeyboardButtons::Duration => ListingField::Duration, FieldSelectionKeyboardButtons::Duration => ListingField::EndTime,
FieldSelectionKeyboardButtons::Done => { FieldSelectionKeyboardButtons::Done => {
return Err(anyhow::anyhow!("Done button should not be used here")) return Err(anyhow::anyhow!("Done button should not be used here"))
} }
@@ -279,31 +270,28 @@ pub async fn enter_edit_listing_draft(
/// Save the listing to the database /// Save the listing to the database
async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> HandlerResult<String> { async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> HandlerResult<String> {
let (listing, success_message) = match draft.persisted { let (listing, success_message) = if let Some(fields) = draft.persisted {
ListingDraftPersisted::New(fields) => { let listing = ListingDAO::update_listing(
let listing = ListingDAO::insert_listing( db_pool,
db_pool, PersistedListing {
NewListing { persisted: fields,
persisted: fields, base: draft.base,
base: draft.base, fields: draft.fields,
fields: draft.fields, },
}, )
) .await?;
.await?; (listing, "Listing updated!")
(listing, "Listing created!") } else {
} let listing = ListingDAO::insert_listing(
ListingDraftPersisted::Persisted(fields) => { db_pool,
let listing = ListingDAO::update_listing( NewListing {
db_pool, persisted: (),
PersistedListing { base: draft.base,
persisted: fields, fields: draft.fields,
base: draft.base, },
fields: draft.fields, )
}, .await?;
) (listing, "Listing created!")
.await?;
(listing, "Listing updated!")
}
}; };
Ok(format!("{success_message}: {}", listing.base.title)) Ok(format!("{success_message}: {}", listing.base.title))
@@ -314,62 +302,14 @@ fn get_current_field_value(
draft: &ListingDraft, draft: &ListingDraft,
field: ListingField, field: ListingField,
) -> Result<String, anyhow::Error> { ) -> Result<String, anyhow::Error> {
let value = match field { let step = step_for_field(field, draft.listing_type())
ListingField::Title => draft.base.title.clone(), .ok_or_else(|| anyhow::anyhow!("Cannot get field value for field {field:?}"))?;
ListingField::Description => draft match (step.get_field_value)(draft) {
.base Ok(value) => Ok(value.unwrap_or_else(|| "(none)".to_string())),
.description Err(e) => Err(anyhow::anyhow!(
.as_deref() "Cannot get field value for field {field:?}: {e:?}"
.unwrap_or("(no description)") )),
.to_string(), }
ListingField::Price => match &draft.fields {
ListingFields::FixedPriceListing(fields) => {
format!("${}", fields.buy_now_price)
}
_ => anyhow::bail!("Cannot update price for non-fixed price listing"),
},
ListingField::Slots => match &draft.fields {
ListingFields::FixedPriceListing(fields) => {
format!("{} slots", fields.slots_available)
}
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"),
},
ListingField::StartTime => match &draft.persisted {
ListingDraftPersisted::New(fields) => {
format!("{} hours", fields.start_delay)
}
_ => anyhow::bail!("Cannot update start time of an existing listing"),
},
ListingField::Duration => match &draft.persisted {
ListingDraftPersisted::New(fields) => fields.end_delay.to_string(),
_ => anyhow::bail!("Cannot update duration of an existing listing"),
},
ListingField::StartingBidAmount => match &draft.fields {
ListingFields::BasicAuction(fields) => {
format!("${}", fields.starting_bid)
}
_ => anyhow::bail!("Cannot update starting bid amount for non-basic auction"),
},
ListingField::BuyNowPrice => match &draft.fields {
ListingFields::BasicAuction(fields) => {
format!("${:?}", fields.buy_now_price)
}
_ => anyhow::bail!("Cannot update buy now price for non-basic auction"),
},
ListingField::MinBidIncrement => match &draft.fields {
ListingFields::BasicAuction(fields) => {
format!("${}", fields.min_increment)
}
_ => anyhow::bail!("Cannot update min bid increment for non-basic auction"),
},
ListingField::AntiSnipeMinutes => match &draft.fields {
ListingFields::BasicAuction(fields) => {
format!("{} minutes", fields.anti_snipe_minutes.unwrap_or(5))
}
_ => anyhow::bail!("Cannot update anti-snipe minutes for non-basic auction"),
},
};
Ok(value)
} }
/// Get the edit keyboard for a field /// Get the edit keyboard for a field
@@ -389,7 +329,7 @@ fn get_edit_keyboard_for_field(field: ListingField) -> InlineKeyboardMarkup {
} }
ListingField::StartTime => back_button ListingField::StartTime => back_button
.append_row(StartTimeKeyboardButtons::to_keyboard().inline_keyboard[0].clone()), .append_row(StartTimeKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
ListingField::Duration => back_button ListingField::EndTime => back_button
.append_row(DurationKeyboardButtons::to_keyboard().inline_keyboard[0].clone()), .append_row(DurationKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
_ => back_button, _ => back_button,
} }

View File

@@ -8,6 +8,19 @@ keyboard_buttons! {
} }
} }
keyboard_buttons! {
pub enum CurrencyTypeKeyboardButtons {
[
Usd("🇺🇸 USD", "currency_type_usd"),
Cad("🇨🇦 CAD", "currency_type_cad"),
],
[
Gbp("🇬🇧 GBP", "currency_type_gbp"),
Eur("🇪🇺 EUR", "currency_type_eur"),
],
}
}
keyboard_buttons! { keyboard_buttons! {
pub enum DurationKeyboardButtons { pub enum DurationKeyboardButtons {
OneDay("1 day", "duration_1_day"), OneDay("1 day", "duration_1_day"),
@@ -52,7 +65,7 @@ keyboard_buttons! {
], ],
[ [
Done("✅ Done", "edit_done"), Done("✅ Done", "edit_done"),
] ],
} }
} }

View File

@@ -3,134 +3,264 @@
//! This module centralizes all user-facing messages to eliminate duplication //! This module centralizes all user-facing messages to eliminate duplication
//! and provide a single source of truth for wizard text. //! and provide a single source of truth for wizard text.
use crate::commands::new_listing::keyboard::*;
use crate::commands::new_listing::types::ListingField; use crate::commands::new_listing::types::ListingField;
use crate::commands::new_listing::validations::*;
use crate::commands::new_listing::{keyboard::*, ListingDraft};
use crate::db::listing::ListingFields;
use crate::db::ListingType; use crate::db::ListingType;
use crate::message_utils::format_datetime;
use teloxide::types::InlineKeyboardMarkup; use teloxide::types::InlineKeyboardMarkup;
struct ListingStep { #[derive(Debug)]
field_type: ListingField, pub enum GetFieldError {
field_name: &'static str, UnsupportedListingType,
description: &'static [&'static str], }
pub type GetFieldResult<T> = Result<Option<T>, GetFieldError>;
#[derive(Copy, Clone)]
pub struct ListingStepData {
pub field_type: ListingField,
pub field_name: &'static str,
pub description: &'static [&'static str],
pub get_field_value: fn(&ListingDraft) -> GetFieldResult<String>,
pub set_field_value: fn(&mut ListingDraft, Option<String>) -> Result<(), SetFieldError>,
} }
const FIXED_PRICE_LISTING_STEPS: &[ListingStep] = &[ macro_rules! get_field_mut {
ListingStep { ($fields:expr, $($variant:ident { $field:ident }),+) => {
field_type: ListingField::Title, match &mut $fields {
field_name: "Title", $(
description: &["Please enter a title for your listing (max 100 characters):"], get_field_mut!(@field_name $variant fields) => &mut fields.$field,
)+
_ => return Err(SetFieldError::UnsupportedFieldForListingType),
}
};
(@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) };
(@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) };
(@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) };
(@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) };
}
macro_rules! get_field {
($fields:expr, $($variant:ident { $field:ident }),+) => {
match &$fields {
$(
get_field!(@field_name $variant fields) => Ok(fields.$field),
)+
_ => Err(GetFieldError::UnsupportedListingType),
}
};
(@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) };
(@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) };
(@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) };
(@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) };
}
const TITLE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Title,
field_name: "Title",
description: &["Please enter a title for your listing (max 100 characters)"],
get_field_value: |draft| Ok(Some(draft.base.title.clone())),
set_field_value: |draft, value| {
draft.base.title = require_field(value).and_then(validate_title)?;
Ok(())
}, },
ListingStep { };
field_type: ListingField::Description,
field_name: "Description", const DESCRIPTION_LISTING_STEP_DATA: ListingStepData = ListingStepData {
description: &["Please enter a description for your listing (optional)."], field_type: ListingField::Description,
field_name: "Description",
description: &["Please enter a description for your listing (optional)"],
get_field_value: |draft| Ok(draft.base.description.clone()),
set_field_value: |draft, value| {
draft.base.description = value.map(validate_description).transpose()?;
Ok(())
}, },
ListingStep { };
field_type: ListingField::Price,
field_name: "Price", const CURRENCY_TYPE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
description: &[ field_type: ListingField::CurrencyType,
"Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):", field_name: "Currency Type",
"• Price should be in USD", description: &["Please enter the currency type for your listing (optional)"],
], get_field_value: |draft| Ok(Some(draft.base.currency_type.to_string())),
set_field_value: |draft, value| {
draft.base.currency_type = require_field(value).and_then(validate_currency_type)?;
Ok(())
}, },
ListingStep { };
field_type: ListingField::Slots,
field_name: "Slots", const START_TIME_LISTING_STEP_DATA: ListingStepData = ListingStepData {
description: &[ field_type: ListingField::StartTime,
"How many items are available for sale?", field_name: "Start Time",
"• Choose a common value below or enter a custom number (1-1000):", description: &[
], "When should your listing start?",
"• Click 'Now' to start immediately",
"• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)",
"• Maximum delay: 168 hours (7 days)",
],
get_field_value: |draft| {
Ok(Some(format_datetime(draft.base.starts_at)))
}, },
ListingStep { set_field_value: |draft, value| {
field_type: ListingField::StartTime, draft.base.starts_at = require_field(value).and_then(validate_start_time)?;
field_name: "Start Time", Ok(())
description: &[
"When should your listing start?",
"• Click 'Now' to start immediately",
"• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)",
"• Maximum delay: 168 hours (7 days)",
],
}, },
ListingStep { };
field_type: ListingField::Duration,
field_name: "Duration", const DURATION_LISTING_STEP_DATA: ListingStepData = ListingStepData {
description: &[ field_type: ListingField::EndTime,
"How long should your listing run?", field_name: "End Time",
"• Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):", description: &[
], "When should your listing end?",
"• Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):",
],
get_field_value: |draft| Ok(Some(format_datetime(draft.base.ends_at))),
set_field_value: |draft, value| {
draft.base.ends_at =
draft.base.starts_at + require_field(value).and_then(validate_duration)?;
Ok(())
}, },
};
const PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Price,
field_name: "Price",
description: &[
"Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99)",
"• Price should be in USD",
],
get_field_value: |draft| {
let buy_now_price = get_field!(draft.fields, FixedPriceListingFields { buy_now_price })?;
Ok(Some(format!("${buy_now_price}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, FixedPriceListingFields { buy_now_price }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const SLOTS_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Slots,
field_name: "Slots",
description: &[
"How many items are available for sale?",
"• Choose a common value below or enter a custom number (1-1000)",
],
get_field_value: |draft| {
let slots_available =
get_field!(draft.fields, FixedPriceListingFields { slots_available })?;
Ok(Some(format!("{slots_available} slots")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, FixedPriceListingFields { slots_available }) =
require_field(value).and_then(validate_slots)?;
Ok(())
},
};
const STARTING_BID_AMOUNT_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::StartingBidAmount,
field_name: "Starting Bid Amount",
description: &[
"Please enter the starting bid amount for your auction (e.g., 10.50, 25, 0.99):",
"• Starting bid should be in USD",
],
get_field_value: |draft| {
let starting_bid_amount = get_field!(draft.fields, BasicAuctionFields { starting_bid })?;
Ok(Some(format!("${starting_bid_amount}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { starting_bid }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const BUY_NOW_PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::BuyNowPrice,
field_name: "Buy Now Price",
description: &[
"Please enter the buy now price for your auction (e.g., 10.50, 25, 0.99):",
"• Buy now price should be in USD",
],
get_field_value: |draft| {
let buy_now_price = get_field!(draft.fields, BasicAuctionFields { buy_now_price })?;
Ok(buy_now_price.map(|price| format!("${price}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { buy_now_price }) =
value.map(validate_price).transpose()?;
Ok(())
},
};
const BID_INCREMENT_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::MinBidIncrement,
field_name: "Minimum Bid Increment",
description: &[
"Please enter the minimum bid increment for your auction (e.g., 10.50, 25, 0.99):",
"• Default: $1.00",
"• Minimum bid increment should be in USD",
],
get_field_value: |draft| {
let min_bid_increment = get_field!(draft.fields, BasicAuctionFields { min_increment })?;
Ok(Some(format!("${min_bid_increment}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { min_increment }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const ANTI_SNIPE_MINUTES_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::AntiSnipeMinutes,
field_name: "Anti-Snipe Minutes",
description: &[
"Please enter the anti-snipe minutes for your auction (e.g., 10, 15, 20):",
"• Default: 5 minutes",
"• Anti-snipe will extend the auction duration by the number of minutes entered",
"• Anti-snipe minutes should be in minutes",
],
get_field_value: |draft| {
let anti_snipe_minutes =
get_field!(draft.fields, BasicAuctionFields { anti_snipe_minutes })?;
Ok(anti_snipe_minutes.map(|minutes| format!("{minutes} minutes")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { anti_snipe_minutes }) =
value.map(validate_anti_snipe_minutes).transpose()?;
Ok(())
},
};
const FIXED_PRICE_LISTING_STEPS: &[ListingStepData] = &[
TITLE_LISTING_STEP_DATA,
DESCRIPTION_LISTING_STEP_DATA,
CURRENCY_TYPE_LISTING_STEP_DATA,
PRICE_LISTING_STEP_DATA,
SLOTS_LISTING_STEP_DATA,
START_TIME_LISTING_STEP_DATA,
DURATION_LISTING_STEP_DATA,
]; ];
const BASIC_AUCTION_STEPS: &[ListingStep] = &[ const BASIC_AUCTION_STEPS: &[ListingStepData] = &[
ListingStep { TITLE_LISTING_STEP_DATA,
field_type: ListingField::Title, DESCRIPTION_LISTING_STEP_DATA,
description: &["Please enter a title for your listing (max 100 characters):"], CURRENCY_TYPE_LISTING_STEP_DATA,
field_name: "Title", STARTING_BID_AMOUNT_LISTING_STEP_DATA,
}, BUY_NOW_PRICE_LISTING_STEP_DATA,
ListingStep { BID_INCREMENT_LISTING_STEP_DATA,
field_type: ListingField::Description, ANTI_SNIPE_MINUTES_LISTING_STEP_DATA,
description: &["Please enter a description for your listing (optional)."], START_TIME_LISTING_STEP_DATA,
field_name: "Description", DURATION_LISTING_STEP_DATA,
},
ListingStep {
field_type: ListingField::StartingBidAmount,
field_name: "Starting Bid Amount",
description: &[
"Please enter the starting bid amount for your auction (e.g., 10.50, 25, 0.99):",
"• Starting bid should be in USD",
],
},
ListingStep {
field_type: ListingField::BuyNowPrice,
field_name: "Buy Now Price",
description: &[
"Please enter the buy now price for your auction (e.g., 10.50, 25, 0.99):",
"• Buy now price should be in USD",
],
},
ListingStep {
field_type: ListingField::MinBidIncrement,
field_name: "Minimum Bid Increment",
description: &[
"Please enter the minimum bid increment for your auction (e.g., 10.50, 25, 0.99):",
"• Default: $1.00",
"• Minimum bid increment should be in USD",
],
},
ListingStep {
field_type: ListingField::AntiSnipeMinutes,
field_name: "Anti-Snipe Minutes",
description: &[
"Please enter the anti-snipe minutes for your auction (e.g., 10, 15, 20):",
"• Default: 5 minutes",
"• Anti-snipe will extend the auction duration by the number of minutes entered",
"• Anti-snipe minutes should be in minutes",
],
},
ListingStep {
field_type: ListingField::StartTime,
field_name: "Start Time",
description: &[
"When should the auction start?",
"• Click 'Now' to start immediately",
"• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)",
"• Maximum delay: 168 hours (7 days)",
],
},
ListingStep {
field_type: ListingField::Duration,
field_name: "Duration",
description: &[
"How long should your auction run?",
"• Click 'Now' to start immediately",
"• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)",
],
},
]; ];
const BLIND_AUCTION_STEPS: &[ListingStep] = &[];
const MULTI_SLOT_AUCTION_STEPS: &[ListingStep] = &[];
fn steps_for_listing_type(listing_type: ListingType) -> &'static [ListingStep] { const BLIND_AUCTION_STEPS: &[ListingStepData] = &[];
const MULTI_SLOT_AUCTION_STEPS: &[ListingStepData] = &[];
pub fn steps_for_listing_type(listing_type: ListingType) -> &'static [ListingStepData] {
match listing_type { match listing_type {
ListingType::FixedPriceListing => FIXED_PRICE_LISTING_STEPS, ListingType::FixedPriceListing => FIXED_PRICE_LISTING_STEPS,
ListingType::BasicAuction => BASIC_AUCTION_STEPS, ListingType::BasicAuction => BASIC_AUCTION_STEPS,
@@ -148,7 +278,10 @@ fn get_total_steps(listing_type: ListingType) -> usize {
steps_for_listing_type(listing_type).len() steps_for_listing_type(listing_type).len()
} }
fn step_for_field(field: ListingField, listing_type: ListingType) -> Option<&'static ListingStep> { pub fn step_for_field(
field: ListingField,
listing_type: ListingType,
) -> Option<&'static ListingStepData> {
steps_for_listing_type(listing_type) steps_for_listing_type(listing_type)
.iter() .iter()
.find(|step| step.field_type == field) .find(|step| step.field_type == field)
@@ -202,10 +335,11 @@ pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarku
NavKeyboardButtons::Back.to_button(), NavKeyboardButtons::Back.to_button(),
NavKeyboardButtons::Skip.to_button(), NavKeyboardButtons::Skip.to_button(),
]])), ]])),
ListingField::CurrencyType => Some(CurrencyTypeKeyboardButtons::to_keyboard()),
ListingField::Price => None, ListingField::Price => None,
ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()), ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()),
ListingField::StartTime => Some(StartTimeKeyboardButtons::to_keyboard()), ListingField::StartTime => Some(StartTimeKeyboardButtons::to_keyboard()),
ListingField::Duration => Some(DurationKeyboardButtons::to_keyboard()), ListingField::EndTime => Some(DurationKeyboardButtons::to_keyboard()),
// TODO - Add keyboards for these fields // TODO - Add keyboards for these fields
ListingField::StartingBidAmount => None, ListingField::StartingBidAmount => None,
ListingField::BuyNowPrice => Some(InlineKeyboardMarkup::new([[ ListingField::BuyNowPrice => Some(InlineKeyboardMarkup::new([[
@@ -233,5 +367,5 @@ pub fn get_listing_type_selection_message() -> &'static str {
/// Get the keyboard for listing type selection /// Get the keyboard for listing type selection
pub fn get_listing_type_keyboard() -> InlineKeyboardMarkup { pub fn get_listing_type_keyboard() -> InlineKeyboardMarkup {
ListingTypeKeyboardButtons::to_keyboard() ListingTypeKeyboardButtons::to_keyboard().append_row([NavKeyboardButtons::Back.to_button()])
} }

View File

@@ -16,7 +16,7 @@ mod callbacks;
mod field_processing; mod field_processing;
mod handler_factory; mod handler_factory;
mod handlers; mod handlers;
mod keyboard; pub mod keyboard;
pub mod messages; pub mod messages;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,22 +1,28 @@
use chrono::Duration;
use crate::{ use crate::{
assert_timestamps_approx_eq,
commands::new_listing::{ commands::new_listing::{
field_processing::update_field_on_draft, field_processing::update_field_on_draft,
types::{ListingDraft, ListingDraftPersisted, ListingField}, types::{ListingDraft, ListingField},
}, },
db::{ db::{
listing::{FixedPriceListingFields, ListingFields, NewListingFields}, listing::{FixedPriceListingFields, ListingFields},
ListingDuration, MoneyAmount, UserDbId, CurrencyType, MoneyAmount, UserDbId,
}, },
}; };
fn create_test_draft() -> ListingDraft { fn create_test_draft() -> ListingDraft {
ListingDraft { ListingDraft {
has_changes: false, has_changes: false,
persisted: ListingDraftPersisted::New(NewListingFields::default()), persisted: None,
base: crate::db::listing::ListingBase { base: crate::db::listing::ListingBase {
seller_id: UserDbId::new(1), seller_id: UserDbId::new(1),
title: "".to_string(), title: "".to_string(),
description: None, description: None,
currency_type: CurrencyType::Usd,
starts_at: chrono::Utc::now(),
ends_at: chrono::Utc::now() + chrono::Duration::hours(1),
}, },
fields: ListingFields::FixedPriceListing(FixedPriceListingFields { fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::default(), buy_now_price: MoneyAmount::default(),
@@ -40,12 +46,12 @@ fn test_complete_field_processing_workflow() {
), ),
(ListingField::Price, "34.99"), (ListingField::Price, "34.99"),
(ListingField::Slots, "2"), (ListingField::Slots, "2"),
(ListingField::StartTime, "1"), (ListingField::StartTime, "1 hour"),
(ListingField::Duration, "3 days"), (ListingField::EndTime, "3 days"),
]; ];
for (field, input) in workflow { for (field, input) in workflow {
let result = update_field_on_draft(field, &mut draft, input); let result = update_field_on_draft(field, &mut draft, Some(input));
assert!(result.is_ok(), "Processing {field:?} should succeed"); assert!(result.is_ok(), "Processing {field:?} should succeed");
} }
@@ -66,50 +72,16 @@ fn test_complete_field_processing_workflow() {
assert_eq!(fields.slots_available, 2); assert_eq!(fields.slots_available, 2);
} }
if let ListingDraftPersisted::New(fields) = &draft.persisted { assert_timestamps_approx_eq!(
assert_eq!(fields.start_delay, ListingDuration::hours(1)); draft.base.starts_at,
assert_eq!(fields.end_delay, ListingDuration::hours(72)); // 3 days chrono::Utc::now() + Duration::hours(1),
} Duration::milliseconds(100)
}
#[test]
fn test_persisted_listing_edit_restrictions() {
let mut draft = ListingDraft {
has_changes: false,
persisted: ListingDraftPersisted::Persisted(crate::db::listing::PersistedListingFields {
id: crate::db::ListingDbId::new(1),
start_at: chrono::Utc::now(),
end_at: chrono::Utc::now() + chrono::Duration::hours(24),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}),
base: crate::db::listing::ListingBase {
seller_id: UserDbId::new(1),
title: "Existing Listing".to_string(),
description: None,
},
fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::from_str("10.00").unwrap(),
slots_available: 1,
}),
};
// Critical business rule: Cannot modify timing of live listings
let start_time_result = update_field_on_draft(ListingField::StartTime, &mut draft, "5");
assert!(
start_time_result.is_err(),
"Live listings cannot change start time"
); );
assert_timestamps_approx_eq!(
let duration_result = update_field_on_draft(ListingField::Duration, &mut draft, "48"); draft.base.ends_at,
assert!( chrono::Utc::now() + Duration::hours(72) + Duration::hours(1),
duration_result.is_err(), Duration::milliseconds(100)
"Live listings cannot change duration"
); );
// But content can be updated
let title_result = update_field_on_draft(ListingField::Title, &mut draft, "Updated Title");
assert!(title_result.is_ok(), "Live listings can update content");
} }
#[test] #[test]
@@ -124,15 +96,14 @@ fn test_natural_language_duration_conversion() {
]; ];
for (input, expected_hours) in business_durations { for (input, expected_hours) in business_durations {
update_field_on_draft(ListingField::Duration, &mut draft, input).unwrap(); update_field_on_draft(ListingField::EndTime, &mut draft, Some(input)).unwrap();
if let ListingDraftPersisted::New(fields) = &draft.persisted { assert_timestamps_approx_eq!(
assert_eq!( draft.base.ends_at,
fields.end_delay, chrono::Utc::now() + chrono::Duration::hours(expected_hours),
ListingDuration::hours(expected_hours), Duration::milliseconds(100),
"Business duration '{input}' should convert correctly" "Business duration '{input}' should convert correctly"
); );
}
} }
} }
@@ -141,8 +112,8 @@ fn test_price_and_slots_consistency_for_fixed_price_listings() {
let mut draft = create_test_draft(); let mut draft = create_test_draft();
// Test that price and slots work together correctly for business logic // Test that price and slots work together correctly for business logic
update_field_on_draft(ListingField::Price, &mut draft, "25.00").unwrap(); update_field_on_draft(ListingField::Price, &mut draft, Some("25.00")).unwrap();
update_field_on_draft(ListingField::Slots, &mut draft, "5").unwrap(); update_field_on_draft(ListingField::Slots, &mut draft, Some("5")).unwrap();
if let ListingFields::FixedPriceListing(fields) = &draft.fields { if let ListingFields::FixedPriceListing(fields) = &draft.fields {
assert_eq!( assert_eq!(

View File

@@ -2,19 +2,19 @@ use crate::{
db::{ db::{
listing::{ listing::{
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase, BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase,
ListingFields, MultiSlotAuctionFields, NewListingFields, PersistedListing, ListingFields, MultiSlotAuctionFields, PersistedListing, PersistedListingFields,
PersistedListingFields,
}, },
ListingType, MoneyAmount, UserDbId, CurrencyType, ListingType, MoneyAmount, UserDbId,
}, },
DialogueRootState, DialogueRootState,
}; };
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ListingDraft { pub struct ListingDraft {
pub has_changes: bool, pub has_changes: bool,
pub persisted: ListingDraftPersisted, pub persisted: Option<PersistedListingFields>,
pub base: ListingBase, pub base: ListingBase,
pub fields: ListingFields, pub fields: ListingFields,
} }
@@ -50,11 +50,14 @@ impl ListingDraft {
Self { Self {
has_changes: false, has_changes: false,
persisted: ListingDraftPersisted::New(NewListingFields::default()), persisted: None,
base: ListingBase { base: ListingBase {
seller_id, seller_id,
currency_type: CurrencyType::Usd,
title: "".to_string(), title: "".to_string(),
description: None, description: None,
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
}, },
fields, fields,
} }
@@ -63,7 +66,7 @@ impl ListingDraft {
pub fn from_persisted(listing: PersistedListing) -> Self { pub fn from_persisted(listing: PersistedListing) -> Self {
Self { Self {
has_changes: false, has_changes: false,
persisted: ListingDraftPersisted::Persisted(listing.persisted), persisted: Some(listing.persisted),
base: listing.base, base: listing.base,
fields: listing.fields, fields: listing.fields,
} }
@@ -74,20 +77,15 @@ impl ListingDraft {
} }
} }
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum ListingDraftPersisted {
New(NewListingFields),
Persisted(PersistedListingFields),
}
#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] #[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
pub enum ListingField { pub enum ListingField {
Title, Title,
Description, Description,
CurrencyType,
Price, Price,
Slots, Slots,
StartTime, StartTime,
Duration, EndTime,
StartingBidAmount, StartingBidAmount,
BuyNowPrice, BuyNowPrice,
MinBidIncrement, MinBidIncrement,

View File

@@ -4,14 +4,11 @@
//! listing summaries, confirmation screens, and edit interfaces. //! listing summaries, confirmation screens, and edit interfaces.
use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons; use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons;
use crate::commands::new_listing::messages::steps_for_listing_type;
use crate::commands::new_listing::NewListingState; use crate::commands::new_listing::NewListingState;
use crate::db::ListingType;
use crate::RootDialogue; use crate::RootDialogue;
use crate::{ use crate::{commands::new_listing::types::ListingDraft, message_utils::*, HandlerResult};
commands::new_listing::types::{ListingDraft, ListingDraftPersisted},
db::listing::ListingFields,
message_utils::*,
HandlerResult,
};
use teloxide::{types::InlineKeyboardMarkup, Bot}; use teloxide::{types::InlineKeyboardMarkup, Bot};
/// Display the listing summary with optional flash message and keyboard /// Display the listing summary with optional flash message and keyboard
@@ -23,6 +20,7 @@ pub async fn display_listing_summary(
flash_message: Option<String>, flash_message: Option<String>,
) -> HandlerResult { ) -> HandlerResult {
let mut response_lines = vec![]; let mut response_lines = vec![];
let listing_type: ListingType = (&draft.fields).into();
if let Some(flash_message) = flash_message { if let Some(flash_message) = flash_message {
response_lines.push(flash_message.to_string()); response_lines.push(flash_message.to_string());
@@ -33,44 +31,21 @@ pub async fn display_listing_summary(
} else { } else {
"" ""
}; };
response_lines.push(format!("📋 <b>Listing Summary</b> {unsaved_changes}"));
response_lines.push("".to_string());
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
response_lines.push(format!( response_lines.push(format!(
"📄 <b>Description:</b> {}", "📋 <b>{listing_type} Summary</b> {unsaved_changes}"
draft
.base
.description
.as_deref()
.unwrap_or("<i>No description</i>")
)); ));
response_lines.push("".to_string());
if let ListingFields::FixedPriceListing(fields) = &draft.fields { for step in steps_for_listing_type(listing_type) {
response_lines.push(format!( let field_value = match (step.get_field_value)(draft) {
"💰 <b>Buy it Now Price:</b> ${}", Ok(value) => value.unwrap_or_else(|| "(none)".to_string()),
fields.buy_now_price Err(_) => continue,
)); };
} response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
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> {}",
format_datetime(fields.start_at)
));
response_lines.push(format!(
"<b>Ends on:</b> {}",
format_datetime(fields.end_at)
));
}
} }
response_lines.push("".to_string()); response_lines.push("".to_string());
response_lines.push("Please review your listing and choose an action:".to_string()); response_lines.push("Edit your listing:".to_string());
send_message(bot, target, response_lines.join("\n"), keyboard).await?; send_message(bot, target, response_lines.join("\n"), keyboard).await?;
@@ -85,17 +60,18 @@ pub async fn enter_confirm_save_listing(
draft: ListingDraft, draft: ListingDraft,
flash: Option<String>, flash: Option<String>,
) -> HandlerResult { ) -> HandlerResult {
let keyboard = match draft.persisted { let keyboard = if draft.persisted.is_some() {
ListingDraftPersisted::New(_) => InlineKeyboardMarkup::default().append_row([ InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Create.to_button(),
ConfirmationKeyboardButtons::Edit.to_button(),
ConfirmationKeyboardButtons::Discard.to_button(),
]),
ListingDraftPersisted::Persisted(_) => InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Save.to_button(), ConfirmationKeyboardButtons::Save.to_button(),
ConfirmationKeyboardButtons::Edit.to_button(), ConfirmationKeyboardButtons::Edit.to_button(),
ConfirmationKeyboardButtons::Discard.to_button(),
])
} else {
InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Create.to_button(),
ConfirmationKeyboardButtons::Edit.to_button(),
ConfirmationKeyboardButtons::Cancel.to_button(), ConfirmationKeyboardButtons::Cancel.to_button(),
]), ])
}; };
display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?; display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?;

View File

@@ -1,126 +1,147 @@
use crate::db::{ListingDuration, MoneyAmount}; use chrono::{DateTime, Duration, Utc};
use crate::db::{CurrencyType, MoneyAmount};
#[derive(Debug)]
pub enum SetFieldError {
FieldRequired,
ValidationFailed(String),
UnsupportedFieldForListingType,
}
type SetFieldResult<T> = Result<T, SetFieldError>;
fn validation_failed<T>(message: &str) -> SetFieldResult<T> {
Err(SetFieldError::ValidationFailed(message.to_string()))
}
macro_rules! validation_failed {
($message:expr) => {
return validation_failed($message)
};
}
pub fn require_field(field: Option<String>) -> SetFieldResult<String> {
field.ok_or(SetFieldError::FieldRequired)
}
// Common input validation functions // Common input validation functions
pub fn validate_title(text: &str) -> Result<String, String> { pub fn validate_title(text: impl AsRef<str>) -> SetFieldResult<String> {
let text = text.as_ref();
if text.is_empty() { if text.is_empty() {
return Err("❌ Title cannot be empty. Please enter a title for your listing:".to_string()); validation_failed!("❌ Title cannot be empty");
} }
if text.len() > 100 { if text.len() > 100 {
return Err( validation_failed!("❌ Title is too long (max 100 characters)");
"❌ Title is too long (max 100 characters). Please enter a shorter title:".to_string(),
);
} }
Ok(text.to_string()) Ok(text.to_string())
} }
pub fn validate_description(text: &str) -> Result<String, String> { pub fn validate_description(text: impl AsRef<str>) -> SetFieldResult<String> {
let text = text.as_ref();
if text.len() > 1000 { if text.len() > 1000 {
return Err( validation_failed!("❌ Description is too long (max 1000 characters)");
"❌ Description is too long (max 1000 characters). Please enter a shorter description:"
.to_string(),
);
} }
Ok(text.to_string()) Ok(text.to_string())
} }
pub fn validate_price(text: &str) -> Result<MoneyAmount, String> { pub fn validate_currency_type(text: impl AsRef<str>) -> SetFieldResult<CurrencyType> {
let text = text.as_ref();
if let Ok(currency_type) = CurrencyType::try_from(text) {
Ok(currency_type)
} else {
validation_failed!("❌ Invalid currency type");
}
}
pub fn validate_price(text: impl AsRef<str>) -> SetFieldResult<MoneyAmount> {
let text = text.as_ref();
match MoneyAmount::from_str(text) { match MoneyAmount::from_str(text) {
Ok(amount) => { Ok(amount) => {
if amount.cents() <= 0 { if amount.cents() <= 0 {
Err("❌ Price must be greater than $0.00. Please enter a valid price:".to_string()) validation_failed!("❌ Price must be greater than $0.00");
} else { } else {
Ok(amount) Ok(amount)
} }
} }
Err(_) => Err( Err(_) => validation_failed!(
"❌ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):" "❌ Invalid price format (use decimal format, e.g., 10.50, 25, 0.99):"
.to_string(),
), ),
} }
} }
pub fn validate_slots(text: &str) -> Result<i32, String> { pub fn validate_slots(text: impl AsRef<str>) -> SetFieldResult<i32> {
let text = text.as_ref();
match text.parse::<i32>() { match text.parse::<i32>() {
Ok(slots) if (1..=1000).contains(&slots) => Ok(slots), Ok(slots) if (1..=1000).contains(&slots) => Ok(slots),
Ok(_) => Err( Ok(_) => validation_failed!("❌ Number of slots must be between 1 and 1000"),
"❌ Number of slots must be between 1 and 1000. Please enter a valid number:" Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 1 to 1000"),
.to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter a number from 1 to 1000:".to_string()),
} }
} }
pub fn validate_duration(text: &str) -> Result<ListingDuration, String> { pub fn validate_time(text: impl AsRef<str>, field_name: &str) -> SetFieldResult<Duration> {
let text = text.as_ref();
let text = text.trim().to_lowercase(); let text = text.trim().to_lowercase();
// Try to parse as plain number first (backwards compatibility) // Try to parse as plain number first (backwards compatibility)
if let Ok(hours) = text.parse::<i32>() { if let Ok(hours) = text.parse::<i32>() {
if (1..=720).contains(&hours) { if (1..=720).contains(&hours) {
return Ok(ListingDuration::hours(hours)); return Ok(Duration::hours(hours as i64));
} else { } else {
return Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string()); validation_failed!(&format!(
"{field_name} must be between 1 hour and 30 days (720 hours)"
));
} }
} }
// Parse natural language duration // Parse natural language duration
let parts: Vec<&str> = text.split_whitespace().collect(); let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() != 2 { let (number_str, unit) = if parts.len() == 2 {
return Err( (parts[0], parts[1])
"❌ Please enter duration like '1 hour', '7 days', or just hours (1-720):".to_string(), } else if parts.len() == 1 {
); (text.as_str(), "hour")
} } else {
validation_failed!(&format!(
let number_str = parts[0]; "❌ Please enter {field_name} like '1 hour', '7 days', or just hours (1-720)"
let unit = parts[1]; ));
};
let number = match number_str.parse::<i32>() { let number = match number_str.parse::<i32>() {
Ok(n) if n > 0 => n, Ok(n) if n > 0 => n,
_ => { _ => validation_failed!(&format!(
return Err( "{field_name} number must be a positive integer"
"❌ Duration number must be a positive integer. Please enter a valid duration:" )),
.to_string(),
)
}
}; };
let hours = match unit { let hours = match unit {
"hour" | "hours" | "hr" | "hrs" => number, "hour" | "hours" | "hr" | "hrs" | "h" | "hs" => number,
"day" | "days" => number * 24, "day" | "days" | "d" | "ds" => number * 24,
_ => { _ => validation_failed!("❌ Supported units: hour(s), day(s)"),
return Err(
"❌ Supported units: hour(s), day(s). Please enter a valid duration:".to_string(),
)
}
}; };
if (1..=720).contains(&hours) { if (1..=720).contains(&hours) {
Ok(ListingDuration::hours(hours)) Ok(Duration::hours(hours as i64))
} else { } else {
Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string()) validation_failed!(&format!(
"{field_name} must be between 1 hour and 30 days (720 hours)"
));
} }
} }
pub fn validate_start_time(text: &str) -> Result<ListingDuration, String> { pub fn validate_duration(text: impl AsRef<str>) -> SetFieldResult<Duration> {
match text.parse::<i32>() { validate_time(text, "Duration")
Ok(hours) if (0..=168).contains(&hours) => Ok(ListingDuration::hours(hours)), // Max 1 week delay
Ok(_) => Err(
"❌ Start time must be between 0 and 168 hours. Please enter a valid number:"
.to_string(),
),
Err(_) => Err(
"❌ Invalid number. Please enter number of hours (0 for immediate start):".to_string(),
),
}
} }
pub fn validate_anti_snipe_minutes(text: &str) -> Result<i32, String> { pub fn validate_start_time(text: impl AsRef<str>) -> SetFieldResult<DateTime<Utc>> {
validate_time(text, "Start Time").map(|duration| Utc::now() + duration)
}
pub fn validate_anti_snipe_minutes(text: impl AsRef<str>) -> SetFieldResult<i32> {
let text = text.as_ref();
match text.parse::<i32>() { match text.parse::<i32>() {
Ok(minutes) if (0..=1440).contains(&minutes) => Ok(minutes), Ok(minutes) if (0..=1440).contains(&minutes) => Ok(minutes),
Ok(_) => Err( Ok(_) => validation_failed!("❌ Anti-snipe minutes must be between 0 and 1440"),
"❌ Anti-snipe minutes must be between 0 and 1440. Please enter a valid number:" Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 0 to 1440"),
.to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter a number from 0 to 1440:".to_string()),
} }
} }
@@ -130,13 +151,13 @@ mod tests {
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
#[case("24", ListingDuration::hours(24))] // Plain number #[case("24", Duration::hours(24))] // Plain number
#[case("1 hour", ListingDuration::hours(1))] #[case("1 hour", Duration::hours(1))]
#[case("2 hours", ListingDuration::hours(2))] #[case("2 hours", Duration::hours(2))]
#[case("1 day", ListingDuration::hours(24))] #[case("1 day", Duration::hours(24))]
#[case("7 days", ListingDuration::hours(168))] #[case("7 days", Duration::hours(168))]
#[case("30 days", ListingDuration::hours(720))] // Max 30 days #[case("30 days", Duration::hours(720))] // Max 30 days
fn test_validate_duration_valid(#[case] input: &str, #[case] expected: ListingDuration) { fn test_validate_duration_valid(#[case] input: &str, #[case] expected: Duration) {
let result = validate_duration(input).unwrap(); let result = validate_duration(input).unwrap();
assert_eq!(result, expected); assert_eq!(result, expected);
} }

View File

@@ -3,7 +3,7 @@
//! Provides encapsulated CRUD operations for Listing entities //! Provides encapsulated CRUD operations for Listing entities
use anyhow::Result; use anyhow::Result;
use chrono::{Duration, Utc}; use chrono::Utc;
use itertools::Itertools; use itertools::Itertools;
use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool}; use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool};
use std::fmt::Debug; use std::fmt::Debug;
@@ -27,6 +27,7 @@ const LISTING_RETURN_FIELDS: &[&str] = &[
"listing_type", "listing_type",
"title", "title",
"description", "description",
"currency_type",
"starts_at", "starts_at",
"ends_at", "ends_at",
"created_at", "created_at",
@@ -45,13 +46,11 @@ impl ListingDAO {
listing: NewListing, listing: NewListing,
) -> Result<PersistedListing> { ) -> Result<PersistedListing> {
let now = Utc::now(); 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 binds = binds_for_listing(&listing) let binds = binds_for_listing(&listing)
.push("seller_id", &listing.base.seller_id) .push("seller_id", &listing.base.seller_id)
.push("starts_at", &start_at) .push("starts_at", &listing.base.starts_at)
.push("ends_at", &end_at) .push("ends_at", &listing.base.ends_at)
.push("created_at", &now) .push("created_at", &now)
.push("updated_at", &now); .push("updated_at", &now);
@@ -155,6 +154,7 @@ fn binds_for_base(base: &ListingBase) -> BindFields {
BindFields::default() BindFields::default()
.push("title", &base.title) .push("title", &base.title)
.push("description", &base.description) .push("description", &base.description)
.push("currency_type", &base.currency_type)
} }
fn binds_for_fields(fields: &ListingFields) -> BindFields { fn binds_for_fields(fields: &ListingFields) -> BindFields {
@@ -187,8 +187,6 @@ impl FromRow<'_, SqliteRow> for PersistedListing {
let listing_type = row.get("listing_type"); let listing_type = row.get("listing_type");
let persisted = PersistedListingFields { let persisted = PersistedListingFields {
id: row.get("id"), id: row.get("id"),
start_at: row.get("starts_at"),
end_at: row.get("ends_at"),
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
}; };
@@ -196,6 +194,9 @@ impl FromRow<'_, SqliteRow> for PersistedListing {
seller_id: row.get("seller_id"), seller_id: row.get("seller_id"),
title: row.get("title"), title: row.get("title"),
description: row.get("description"), description: row.get("description"),
currency_type: row.get("currency_type"),
starts_at: row.get("starts_at"),
ends_at: row.get("ends_at"),
}; };
let fields = match listing_type { let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields { ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {

View File

@@ -9,29 +9,21 @@
//! The main `Listing` enum ensures that only valid fields are accessible for each type. //! The main `Listing` enum ensures that only valid fields are accessible for each type.
//! Database mapping is handled through `ListingRow` with conversion traits. //! Database mapping is handled through `ListingRow` with conversion traits.
use crate::db::{ListingDbId, ListingDuration, ListingType, MoneyAmount, UserDbId}; use crate::db::{CurrencyType, ListingDbId, ListingType, MoneyAmount, UserDbId};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Debug; use std::fmt::Debug;
pub type NewListing = Listing<NewListingFields>; pub type NewListing = Listing<()>;
pub type PersistedListing = Listing<PersistedListingFields>; pub type PersistedListing = Listing<PersistedListingFields>;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields { pub struct PersistedListingFields {
pub id: ListingDbId, pub id: ListingDbId,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_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 /// Main listing/auction entity
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
#[allow(unused)] #[allow(unused)]
@@ -41,6 +33,20 @@ pub struct Listing<P: Debug + Clone> {
pub fields: ListingFields, pub fields: ListingFields,
} }
pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields);
pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields);
impl<'a, P: Debug + Clone> Into<ListingBaseFields<'a>> for &'a Listing<P> {
fn into(self) -> ListingBaseFields<'a> {
(&self.base, &self.fields)
}
}
impl<'a, P: Debug + Clone> Into<ListingBaseFieldsMut<'a>> for &'a mut Listing<P> {
fn into(self) -> ListingBaseFieldsMut<'a> {
(&mut self.base, &mut self.fields)
}
}
/// Common fields shared by all listing types /// Common fields shared by all listing types
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[allow(unused)] #[allow(unused)]
@@ -48,13 +54,16 @@ pub struct ListingBase {
pub seller_id: UserDbId, pub seller_id: UserDbId,
pub title: String, pub title: String,
pub description: Option<String>, pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub currency_type: CurrencyType,
} }
impl ListingBase { impl ListingBase {
#[cfg(test)] #[cfg(test)]
pub fn with_fields(self, fields: ListingFields) -> NewListing { pub fn with_fields(self, fields: ListingFields) -> NewListing {
Listing { Listing {
persisted: NewListingFields::default(), persisted: (),
base: self, base: self,
fields, fields,
} }
@@ -106,9 +115,9 @@ pub enum ListingFields {
BlindAuction(BlindAuctionFields), BlindAuction(BlindAuctionFields),
} }
impl From<&ListingFields> for ListingType { impl ListingFields {
fn from(fields: &ListingFields) -> ListingType { pub fn listing_type(&self) -> ListingType {
match fields { match self {
ListingFields::BasicAuction(_) => ListingType::BasicAuction, ListingFields::BasicAuction(_) => ListingType::BasicAuction,
ListingFields::MultiSlotAuction(_) => ListingType::MultiSlotAuction, ListingFields::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
ListingFields::FixedPriceListing(_) => ListingType::FixedPriceListing, ListingFields::FixedPriceListing(_) => ListingType::FixedPriceListing,
@@ -117,10 +126,17 @@ impl From<&ListingFields> for ListingType {
} }
} }
impl From<&ListingFields> for ListingType {
fn from(fields: &ListingFields) -> ListingType {
fields.listing_type()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::{ListingDAO, TelegramUserDbId}; use crate::db::{ListingDAO, TelegramUserDbId};
use chrono::Duration;
use rstest::rstest; use rstest::rstest;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -173,11 +189,15 @@ mod tests {
seller_id: UserDbId, seller_id: UserDbId,
title: impl Into<String>, title: impl Into<String>,
description: Option<&str>, description: Option<&str>,
currency_type: CurrencyType,
) -> ListingBase { ) -> ListingBase {
ListingBase { ListingBase {
seller_id, seller_id,
title: title.into(), title: title.into(),
description: description.map(|s| s.to_string()), description: description.map(|s| s.to_string()),
currency_type,
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
} }
} }
@@ -190,8 +210,13 @@ mod tests {
async fn test_blind_auction_crud(#[case] fields: ListingFields) { async fn test_blind_auction_crud(#[case] fields: ListingFields) {
let pool = create_test_pool().await; let pool = create_test_pool().await;
let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await; 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")) let new_listing = build_base_listing(
.with_fields(fields); seller_id,
"Test Auction",
Some("Test description"),
CurrencyType::Usd,
)
.with_fields(fields);
// Insert using DAO // Insert using DAO
let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone()) let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone())

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
/// Types of listings supported by the platform /// Types of listings supported by the platform
#[derive(Debug, Clone, PartialEq, Eq, Copy, sqlx::Type)] #[derive(Debug, Clone, PartialEq, Eq, Copy, sqlx::Type)]
#[sqlx(type_name = "TEXT")] #[sqlx(type_name = "TEXT")]
@@ -12,3 +14,29 @@ pub enum ListingType {
/// Blind auction where seller chooses winner /// Blind auction where seller chooses winner
BlindAuction, BlindAuction,
} }
impl ListingType {
pub fn as_str(&self) -> &'static str {
match self {
ListingType::FixedPriceListing => "Fixed Price Listing",
ListingType::BasicAuction => "Basic Auction",
ListingType::MultiSlotAuction => "Multi-Slot Auction",
ListingType::BlindAuction => "Blind Auction",
}
}
pub fn emoji_str(&self) -> &'static str {
match self {
ListingType::FixedPriceListing => "🛍️",
ListingType::BasicAuction => "",
ListingType::MultiSlotAuction => "🎯",
ListingType::BlindAuction => "🎭",
}
}
}
impl Display for ListingType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}

View File

@@ -1,12 +1,17 @@
use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
}; };
/// Currency types supported by the platform /// Currency types supported by the platform
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CurrencyType { pub enum CurrencyType {
#[default] #[default]
Usd, Usd, // United States Dollar
Cad, // Canadian Dollar
Gbp, // British Pound
Eur, // Euro
Jpy, // Japanese Yen
} }
#[allow(unused)] #[allow(unused)]
@@ -15,6 +20,10 @@ impl CurrencyType {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
CurrencyType::Usd => "USD", CurrencyType::Usd => "USD",
CurrencyType::Cad => "CAD",
CurrencyType::Gbp => "GBP",
CurrencyType::Eur => "EUR",
CurrencyType::Jpy => "JPY",
} }
} }
@@ -22,13 +31,24 @@ impl CurrencyType {
pub fn symbol(&self) -> &'static str { pub fn symbol(&self) -> &'static str {
match self { match self {
CurrencyType::Usd => "$", CurrencyType::Usd => "$",
CurrencyType::Cad => "$",
CurrencyType::Gbp => "£",
CurrencyType::Eur => "",
CurrencyType::Jpy => "¥",
} }
} }
}
/// Parse currency from string impl TryFrom<&str> for CurrencyType {
pub fn from_str(s: &str) -> Result<Self, String> { type Error = String;
match s.to_uppercase().as_str() {
"USD" => Ok(CurrencyType::Usd), fn try_from(s: &str) -> Result<Self, String> {
match s.to_uppercase().trim() {
"USD" | "US" | "$" => Ok(CurrencyType::Usd),
"CAD" | "CA" => Ok(CurrencyType::Cad),
"GBP" | "GB" | "£" => Ok(CurrencyType::Gbp),
"EUR" | "EU" | "" => Ok(CurrencyType::Eur),
"JPY" | "JP" | "¥" => Ok(CurrencyType::Jpy),
_ => Err(format!("Unsupported currency: {s}")), _ => Err(format!("Unsupported currency: {s}")),
} }
} }
@@ -70,13 +90,14 @@ impl<'q> Encode<'q, Sqlite> for CurrencyType {
impl<'r> Decode<'r, Sqlite> for CurrencyType { impl<'r> Decode<'r, Sqlite> for CurrencyType {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let currency_str = <&str as Decode<Sqlite>>::decode(value)?; let currency_str = <&str as Decode<Sqlite>>::decode(value)?;
CurrencyType::from_str(currency_str).map_err(Into::into) CurrencyType::try_from(currency_str).map_err(Into::into)
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rstest::rstest;
#[test] #[test]
fn test_currency_type_display() { fn test_currency_type_display() {
@@ -92,15 +113,20 @@ mod tests {
assert_eq!(default_currency, CurrencyType::Usd); assert_eq!(default_currency, CurrencyType::Usd);
} }
#[test] #[rstest]
fn test_currency_type_parsing() { #[case("usd", CurrencyType::Usd)]
let parsed_currency = CurrencyType::from_str("usd").unwrap(); // Case insensitive #[case("USD", CurrencyType::Usd)]
assert_eq!(parsed_currency, CurrencyType::Usd); #[case("USD", CurrencyType::Usd)]
#[case("CA", CurrencyType::Cad)]
fn test_currency_type_parsing(#[case] input: &str, #[case] expected: CurrencyType) {
let parsed_currency = CurrencyType::try_from(input).unwrap();
assert_eq!(parsed_currency, expected);
}
let parsed_upper = CurrencyType::from_str("USD").unwrap(); #[rstest]
assert_eq!(parsed_upper, CurrencyType::Usd); #[case("ASD")]
fn test_currency_type_parsing_invalid(#[case] input: &str) {
let invalid = CurrencyType::from_str("EUR"); let invalid = CurrencyType::try_from(input);
assert!(invalid.is_err()); assert!(invalid.is_err());
} }
} }

View File

@@ -1,19 +1,19 @@
#[macro_export] #[macro_export]
macro_rules! keyboard_buttons { macro_rules! keyboard_buttons {
($vis:vis enum $name:ident { ($vis:vis enum $name:ident {
$($variant:ident($text:literal, $callback_data:literal),)* $($variant:ident($text:literal, $callback_data:literal)),* $(,)?
}) => { }) => {
keyboard_buttons! { keyboard_buttons! {
$vis enum $name { $vis enum $name {
[$($variant($text, $callback_data),)*] [$($variant($text, $callback_data)),*]
} }
} }
}; };
($vis:vis enum $name:ident { ($vis:vis enum $name:ident {
$([ $([
$($variant:ident($text:literal, $callback_data:literal),)* $($variant:ident($text:literal, $callback_data:literal)),* $(,)?
]),* ]),* $(,)?
}) => { }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
$vis enum $name { $vis enum $name {

View File

@@ -51,10 +51,6 @@ impl<'s> From<&'s Chat> for HandleAndId<'s> {
} }
} }
pub fn is_cancel(text: &str) -> bool {
text.eq_ignore_ascii_case("/cancel")
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageTarget { pub struct MessageTarget {
pub chat_id: ChatId, pub chat_id: ChatId,