basic listing stuff
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user