Compare commits
4 Commits
8fb51d12a7
...
9ef36b760e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef36b760e | ||
|
|
5fe4a52c2b | ||
|
|
24819633f5 | ||
|
|
143bf3ce41 |
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -134,6 +134,17 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
@@ -1598,6 +1609,7 @@ name = "pawctioneer-bot"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"env_logger",
|
||||
|
||||
@@ -26,6 +26,7 @@ thiserror = "2.0.16"
|
||||
teloxide-core = "0.13.0"
|
||||
num = "0.4.3"
|
||||
itertools = "0.14.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.26.1"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
case,
|
||||
commands::new_listing::{enter_edit_listing_draft, ListingDraft},
|
||||
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
|
||||
keyboard_buttons,
|
||||
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
|
||||
@@ -18,7 +19,6 @@ use teloxide::{
|
||||
pub enum MyListingsState {
|
||||
ViewingListings,
|
||||
ManagingListing(ListingDbId),
|
||||
EditingListing(ListingDbId),
|
||||
}
|
||||
impl From<MyListingsState> for DialogueRootState {
|
||||
fn from(state: MyListingsState) -> Self {
|
||||
@@ -122,21 +122,13 @@ async fn show_listings_for_user(
|
||||
)]);
|
||||
}
|
||||
|
||||
let mut response = format!(
|
||||
let response = format!(
|
||||
"📋 <b>My Listings</b>\n\n\
|
||||
You have {}.\n\n",
|
||||
You have {}.\n\n\
|
||||
Select a listing to view details",
|
||||
pluralize_with_count(listings.len(), "listing", "listings")
|
||||
);
|
||||
|
||||
// Add each listing with its ID and title
|
||||
for listing in &listings {
|
||||
response.push_str(&format!(
|
||||
"• <b>ID {}:</b> {}\n",
|
||||
listing.persisted.id, listing.base.title
|
||||
));
|
||||
}
|
||||
|
||||
response.push_str("\nTap a listing ID below to view details:");
|
||||
send_message(&bot, target, response, Some(keyboard)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -169,15 +161,13 @@ async fn show_listing_details(
|
||||
let response = format!(
|
||||
"🔍 <b>Viewing Listing Details</b>\n\n\
|
||||
<b>Title:</b> {}\n\
|
||||
<b>Description:</b> {}\n\
|
||||
<b>ID:</b> {}",
|
||||
<b>Description:</b> {}\n",
|
||||
listing.base.title,
|
||||
listing
|
||||
.base
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("No description"),
|
||||
listing.persisted.id
|
||||
);
|
||||
|
||||
send_message(
|
||||
@@ -207,9 +197,8 @@ async fn handle_managing_listing_callback(
|
||||
ManageListingButtons::Edit => {
|
||||
let (_, listing) =
|
||||
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
|
||||
dialogue
|
||||
.update(MyListingsState::EditingListing(listing.persisted.id))
|
||||
.await?;
|
||||
let draft = ListingDraft::from_persisted(listing);
|
||||
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||
}
|
||||
ManageListingButtons::Delete => {
|
||||
ListingDAO::delete_listing(&db_pool, listing_id).await?;
|
||||
|
||||
205
src/commands/new_listing/callbacks.rs
Normal file
205
src/commands/new_listing/callbacks.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Callback handling for the new listing wizard
|
||||
//!
|
||||
//! This module handles all callback query processing for buttons
|
||||
//! in the new listing creation and editing workflows.
|
||||
|
||||
use crate::{
|
||||
commands::new_listing::{
|
||||
field_processing::transition_to_field,
|
||||
keyboard::*,
|
||||
messages::{get_keyboard_for_field, get_step_message},
|
||||
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState},
|
||||
ui::show_confirmation_screen,
|
||||
},
|
||||
db::{listing::ListingFields, ListingDuration},
|
||||
message_utils::*,
|
||||
HandlerResult, RootDialogue,
|
||||
};
|
||||
use log::{error, info};
|
||||
use teloxide::{types::CallbackQuery, Bot};
|
||||
|
||||
/// Handle callbacks during the field input phase
|
||||
pub async fn handle_awaiting_draft_field_callback(
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
(field, draft): (ListingField, ListingDraft),
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
info!("User {from:?} selected callback: {data:?}");
|
||||
let target = (from, message_id);
|
||||
|
||||
if data == "cancel" {
|
||||
return cancel_wizard(&bot, dialogue, target).await;
|
||||
}
|
||||
|
||||
// Unified callback dispatch
|
||||
match field {
|
||||
ListingField::Description if data == "skip" => {
|
||||
handle_description_skip_callback(&bot, dialogue, draft, target).await
|
||||
}
|
||||
ListingField::Slots if data.starts_with("slots_") => {
|
||||
handle_slots_callback(&bot, dialogue, draft, &data, target).await
|
||||
}
|
||||
ListingField::StartTime if data.starts_with("start_time_") => {
|
||||
handle_start_time_callback(&bot, dialogue, draft, &data, target).await
|
||||
}
|
||||
ListingField::Duration if data.starts_with("duration_") => {
|
||||
handle_duration_callback(&bot, dialogue, draft, &data, target).await
|
||||
}
|
||||
_ => {
|
||||
error!("Unknown callback data for field {field:?}: {data}");
|
||||
dialogue.exit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle description skip callback
|
||||
async fn handle_description_skip_callback(
|
||||
bot: &Bot,
|
||||
dialogue: RootDialogue,
|
||||
draft: ListingDraft,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let target = target.into();
|
||||
transition_to_field(dialogue, ListingField::Price, draft).await?;
|
||||
|
||||
let response = format!(
|
||||
"✅ Description skipped!\n\n{}",
|
||||
get_step_message(ListingField::Price)
|
||||
);
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
response,
|
||||
get_keyboard_for_field(ListingField::Price),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle slots selection callback
|
||||
async fn handle_slots_callback(
|
||||
bot: &Bot,
|
||||
dialogue: RootDialogue,
|
||||
mut draft: ListingDraft,
|
||||
data: &str,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let target = target.into();
|
||||
let button = SlotsKeyboardButtons::try_from(data)
|
||||
.map_err(|_| anyhow::anyhow!("Unknown SlotsKeyboardButtons data: {}", data))?;
|
||||
let num_slots = match button {
|
||||
SlotsKeyboardButtons::OneSlot => 1,
|
||||
SlotsKeyboardButtons::TwoSlots => 2,
|
||||
SlotsKeyboardButtons::FiveSlots => 5,
|
||||
SlotsKeyboardButtons::TenSlots => 10,
|
||||
};
|
||||
|
||||
match &mut draft.fields {
|
||||
ListingFields::FixedPriceListing(fields) => {
|
||||
fields.slots_available = num_slots;
|
||||
}
|
||||
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"),
|
||||
}
|
||||
|
||||
transition_to_field(dialogue, ListingField::StartTime, draft).await?;
|
||||
let response = format!(
|
||||
"✅ Available slots: <b>{num_slots}</b>\n\n{}",
|
||||
get_step_message(ListingField::StartTime)
|
||||
);
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
&response,
|
||||
get_keyboard_for_field(ListingField::StartTime),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle start time selection callback
|
||||
async fn handle_start_time_callback(
|
||||
bot: &Bot,
|
||||
dialogue: RootDialogue,
|
||||
mut draft: ListingDraft,
|
||||
data: &str,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let target = target.into();
|
||||
let button = StartTimeKeyboardButtons::try_from(data)
|
||||
.map_err(|_| anyhow::anyhow!("Unknown StartTimeKeyboardButtons data: {}", data))?;
|
||||
let start_time = match button {
|
||||
StartTimeKeyboardButtons::Now => ListingDuration::zero(),
|
||||
};
|
||||
|
||||
match &mut draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
fields.start_delay = start_time;
|
||||
}
|
||||
ListingDraftPersisted::Persisted(_) => {
|
||||
anyhow::bail!("Cannot update start time for persisted listing");
|
||||
}
|
||||
}
|
||||
|
||||
transition_to_field(dialogue, ListingField::Duration, draft).await?;
|
||||
let response = format!(
|
||||
"✅ Listing will start: <b>immediately</b>\n\n{}",
|
||||
get_step_message(ListingField::Duration)
|
||||
);
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
&response,
|
||||
get_keyboard_for_field(ListingField::Duration),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle duration selection callback
|
||||
async fn handle_duration_callback(
|
||||
bot: &Bot,
|
||||
dialogue: RootDialogue,
|
||||
mut draft: ListingDraft,
|
||||
data: &str,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let target = target.into();
|
||||
let button = DurationKeyboardButtons::try_from(data).unwrap();
|
||||
let duration = ListingDuration::days(match button {
|
||||
DurationKeyboardButtons::OneDay => 1,
|
||||
DurationKeyboardButtons::ThreeDays => 3,
|
||||
DurationKeyboardButtons::SevenDays => 7,
|
||||
DurationKeyboardButtons::FourteenDays => 14,
|
||||
});
|
||||
|
||||
match &mut draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
fields.end_delay = duration;
|
||||
}
|
||||
ListingDraftPersisted::Persisted(_) => {
|
||||
anyhow::bail!("Cannot update duration for persisted listing");
|
||||
}
|
||||
}
|
||||
|
||||
show_confirmation_screen(bot, target, &draft).await?;
|
||||
dialogue
|
||||
.update(NewListingState::ViewingDraft(draft))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the wizard and exit
|
||||
pub async fn cancel_wizard(
|
||||
bot: &Bot,
|
||||
dialogue: RootDialogue,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let target = target.into();
|
||||
info!("{target:?} cancelled new listing wizard");
|
||||
dialogue.exit().await?;
|
||||
send_message(bot, target, "❌ Listing creation cancelled.", None).await?;
|
||||
Ok(())
|
||||
}
|
||||
76
src/commands/new_listing/field_processing.rs
Normal file
76
src/commands/new_listing/field_processing.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Field processing logic for the new listing wizard
|
||||
//!
|
||||
//! This module handles the core logic for processing and updating listing fields
|
||||
//! during both initial creation and editing workflows.
|
||||
|
||||
use crate::{
|
||||
commands::new_listing::types::{ListingDraft, ListingDraftPersisted, ListingField},
|
||||
db::listing::ListingFields,
|
||||
HandlerResult, RootDialogue,
|
||||
};
|
||||
use crate::commands::new_listing::{types::NewListingState, validations::*};
|
||||
|
||||
/// Helper function to transition to next field
|
||||
pub async fn transition_to_field(
|
||||
dialogue: RootDialogue,
|
||||
field: ListingField,
|
||||
draft: ListingDraft,
|
||||
) -> HandlerResult {
|
||||
dialogue
|
||||
.update(NewListingState::AwaitingDraftField { field, draft })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process field input and update the draft
|
||||
pub fn process_field_update(
|
||||
field: ListingField,
|
||||
draft: &mut ListingDraft,
|
||||
text: &str,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
match field {
|
||||
ListingField::Title => {
|
||||
draft.base.title = validate_title(text).map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
ListingField::Description => {
|
||||
draft.base.description = Some(validate_description(text).map_err(|e| anyhow::anyhow!(e))?);
|
||||
}
|
||||
ListingField::Price => match &mut draft.fields {
|
||||
ListingFields::FixedPriceListing(fields) => {
|
||||
fields.buy_now_price = validate_price(text).map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
_ => anyhow::bail!("Cannot update price for non-fixed price listing"),
|
||||
},
|
||||
ListingField::Slots => match &mut draft.fields {
|
||||
ListingFields::FixedPriceListing(fields) => {
|
||||
fields.slots_available = validate_slots(text).map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"),
|
||||
},
|
||||
ListingField::StartTime => match &mut draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
fields.start_delay = validate_start_time(text).map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
_ => anyhow::bail!("Cannot update start time of an existing listing"),
|
||||
},
|
||||
ListingField::Duration => match &mut draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
fields.end_delay = validate_duration(text).map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
_ => anyhow::bail!("Cannot update duration of an existing listing"),
|
||||
},
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the next field in the wizard sequence
|
||||
pub fn get_next_field(current_field: ListingField) -> Option<ListingField> {
|
||||
match current_field {
|
||||
ListingField::Title => Some(ListingField::Description),
|
||||
ListingField::Description => Some(ListingField::Price),
|
||||
ListingField::Price => Some(ListingField::Slots),
|
||||
ListingField::Slots => Some(ListingField::StartTime),
|
||||
ListingField::StartTime => Some(ListingField::Duration),
|
||||
ListingField::Duration => None, // Final step
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{callbacks::*, handlers::*, types::*};
|
||||
use crate::{case, Command, DialogueRootState, Handler};
|
||||
use teloxide::{dptree, prelude::*, types::Update};
|
||||
|
||||
|
||||
385
src/commands/new_listing/handlers.rs
Normal file
385
src/commands/new_listing/handlers.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! Main handler functions for the new listing wizard
|
||||
//!
|
||||
//! This module contains the primary handler functions that process
|
||||
//! user input and manage the listing creation workflow.
|
||||
|
||||
use crate::{
|
||||
commands::new_listing::{
|
||||
callbacks::cancel_wizard,
|
||||
field_processing::{get_next_field, process_field_update, transition_to_field},
|
||||
keyboard::{
|
||||
ConfirmationKeyboardButtons, DurationKeyboardButtons, FieldSelectionKeyboardButtons,
|
||||
SlotsKeyboardButtons, StartTimeKeyboardButtons,
|
||||
},
|
||||
messages::{
|
||||
get_edit_success_message, get_keyboard_for_field, get_step_message, get_success_message,
|
||||
},
|
||||
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState},
|
||||
ui::{display_listing_summary, show_confirmation_screen},
|
||||
},
|
||||
db::{
|
||||
listing::{ListingFields, NewListing, PersistedListing},
|
||||
ListingDAO, UserDAO,
|
||||
},
|
||||
message_utils::*,
|
||||
DialogueRootState, HandlerResult, RootDialogue,
|
||||
};
|
||||
use log::{error, info};
|
||||
use sqlx::SqlitePool;
|
||||
use teloxide::{prelude::*, types::*, Bot};
|
||||
|
||||
/// Handle the /newlisting command - starts the dialogue
|
||||
pub async fn handle_new_listing_command(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
msg: Message,
|
||||
) -> HandlerResult {
|
||||
info!(
|
||||
"User {} started new fixed price listing wizard",
|
||||
HandleAndId::from_chat(&msg.chat),
|
||||
);
|
||||
let user = msg.from.ok_or_else(|| anyhow::anyhow!("User not found"))?;
|
||||
let user = UserDAO::find_or_create_by_telegram_user(&db_pool, user).await?;
|
||||
|
||||
// Initialize the dialogue to Start state
|
||||
dialogue
|
||||
.update(NewListingState::AwaitingDraftField {
|
||||
field: ListingField::Title,
|
||||
draft: ListingDraft::new_for_seller(user.persisted.id),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = format!(
|
||||
"🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
|
||||
Let's create your fixed price listing step by step!\n\n{}",
|
||||
get_step_message(ListingField::Title)
|
||||
);
|
||||
|
||||
send_message(
|
||||
&bot,
|
||||
msg.chat,
|
||||
response,
|
||||
get_keyboard_for_field(ListingField::Title),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle text input for any field during creation
|
||||
pub async fn handle_awaiting_draft_field_input(
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
(field, mut draft): (ListingField, ListingDraft),
|
||||
msg: Message,
|
||||
) -> HandlerResult {
|
||||
let chat = msg.chat.clone();
|
||||
let text = msg.text().unwrap_or("");
|
||||
|
||||
info!(
|
||||
"User {} entered input step: {:?}",
|
||||
HandleAndId::from_chat(&chat),
|
||||
field
|
||||
);
|
||||
|
||||
if is_cancel(text) {
|
||||
return cancel_wizard(&bot, dialogue, chat).await;
|
||||
}
|
||||
|
||||
// Process the field update
|
||||
process_field_update(field, &mut draft, text)?;
|
||||
|
||||
// Handle final step or transition to next
|
||||
if let Some(next_field) = get_next_field(field) {
|
||||
transition_to_field(dialogue, next_field, draft).await?;
|
||||
let response = format!(
|
||||
"{}\n\n{}",
|
||||
get_success_message(field),
|
||||
get_step_message(next_field)
|
||||
);
|
||||
send_message(&bot, chat, response, get_keyboard_for_field(next_field)).await
|
||||
} else {
|
||||
// Final step - go to confirmation
|
||||
show_confirmation_screen(&bot, chat, &draft).await?;
|
||||
dialogue
|
||||
.update(NewListingState::ViewingDraft(draft))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle text input for field editing
|
||||
pub async fn handle_editing_field_input(
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
(field, mut draft): (ListingField, ListingDraft),
|
||||
msg: Message,
|
||||
) -> HandlerResult {
|
||||
let chat = msg.chat.clone();
|
||||
let text = msg.text().unwrap_or("").trim();
|
||||
|
||||
info!("User {chat:?} editing field {field:?}");
|
||||
|
||||
// Process the field update
|
||||
process_field_update(field, &mut draft, text)?;
|
||||
|
||||
draft.has_changes = true;
|
||||
enter_edit_listing_draft(
|
||||
&bot,
|
||||
chat,
|
||||
draft,
|
||||
dialogue,
|
||||
Some(get_edit_success_message(field)),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle viewing draft confirmation callbacks
|
||||
pub async fn handle_viewing_draft_callback(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
draft: ListingDraft,
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
|
||||
// Ensure the user exists before saving the listing
|
||||
UserDAO::find_or_create_by_telegram_user(&db_pool, from.clone())
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error finding or creating user: {e}");
|
||||
})?;
|
||||
|
||||
let target = (from.clone(), message_id);
|
||||
|
||||
let button = ConfirmationKeyboardButtons::try_from(data.as_str())
|
||||
.map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?;
|
||||
|
||||
match button {
|
||||
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
|
||||
info!("User {target:?} confirmed listing creation");
|
||||
save_listing(db_pool, bot, target, draft).await?;
|
||||
dialogue.exit().await?;
|
||||
}
|
||||
ConfirmationKeyboardButtons::Cancel => {
|
||||
info!("User {target:?} cancelled listing update");
|
||||
let response = "🗑️ <b>Changes Discarded</b>\n\n\
|
||||
Your changes have been discarded and not saved.";
|
||||
send_message(&bot, target, &response, None).await?;
|
||||
dialogue.exit().await?;
|
||||
}
|
||||
ConfirmationKeyboardButtons::Discard => {
|
||||
info!("User {target:?} discarded listing creation");
|
||||
|
||||
let response = "🗑️ <b>Listing Discarded</b>\n\n\
|
||||
Your listing has been discarded and not created.\n\
|
||||
You can start a new listing anytime with /newlisting.";
|
||||
send_message(&bot, target, &response, None).await?;
|
||||
dialogue.exit().await?;
|
||||
}
|
||||
ConfirmationKeyboardButtons::Edit => {
|
||||
info!("User {target:?} chose to edit listing");
|
||||
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle editing draft field selection callbacks
|
||||
pub async fn handle_editing_draft_callback(
|
||||
bot: Bot,
|
||||
draft: ListingDraft,
|
||||
dialogue: RootDialogue,
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
let target = (from, message_id);
|
||||
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?;
|
||||
|
||||
info!("User {target:?} in editing screen, showing field selection");
|
||||
|
||||
if button == FieldSelectionKeyboardButtons::Done {
|
||||
show_confirmation_screen(&bot, target, &draft).await?;
|
||||
dialogue
|
||||
.update(DialogueRootState::NewListing(
|
||||
NewListingState::ViewingDraft(draft),
|
||||
))
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let field = match button {
|
||||
FieldSelectionKeyboardButtons::Title => ListingField::Title,
|
||||
FieldSelectionKeyboardButtons::Description => ListingField::Description,
|
||||
FieldSelectionKeyboardButtons::Price => ListingField::Price,
|
||||
FieldSelectionKeyboardButtons::Slots => ListingField::Slots,
|
||||
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
|
||||
FieldSelectionKeyboardButtons::Duration => ListingField::Duration,
|
||||
FieldSelectionKeyboardButtons::Done => unreachable!(),
|
||||
};
|
||||
|
||||
let value = get_current_field_value(&draft, field)?;
|
||||
let keyboard = get_edit_keyboard_for_field(field);
|
||||
|
||||
dialogue
|
||||
.update(DialogueRootState::NewListing(
|
||||
NewListingState::EditingDraftField { field, draft },
|
||||
))
|
||||
.await?;
|
||||
|
||||
let response = format!("Editing {field:?}\n\nPrevious value: {value}");
|
||||
send_message(&bot, target, response, Some(keyboard)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle editing draft field callbacks (back button, etc.)
|
||||
pub async fn handle_editing_draft_field_callback(
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
(field, draft): (ListingField, ListingDraft),
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
let target = (from, message_id);
|
||||
info!("User {:?} editing field: {:?} -> {}", target, field, &data);
|
||||
|
||||
if data == "edit_back" {
|
||||
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// This callback handler typically receives button presses, not text input
|
||||
// For now, just redirect back to edit screen since callback data isn't suitable for validation
|
||||
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter the edit listing draft screen
|
||||
pub async fn enter_edit_listing_draft(
|
||||
bot: &Bot,
|
||||
target: impl Into<MessageTarget>,
|
||||
draft: ListingDraft,
|
||||
dialogue: RootDialogue,
|
||||
flash_message: Option<&str>,
|
||||
) -> HandlerResult {
|
||||
display_listing_summary(
|
||||
bot,
|
||||
target,
|
||||
&draft,
|
||||
Some(FieldSelectionKeyboardButtons::to_keyboard()),
|
||||
flash_message,
|
||||
)
|
||||
.await?;
|
||||
dialogue
|
||||
.update(NewListingState::EditingDraft(draft))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the listing to the database
|
||||
async fn save_listing(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
target: impl Into<MessageTarget>,
|
||||
draft: ListingDraft,
|
||||
) -> HandlerResult {
|
||||
let (listing, success_message) = match draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
let listing = ListingDAO::insert_listing(
|
||||
&db_pool,
|
||||
NewListing {
|
||||
persisted: fields,
|
||||
base: draft.base,
|
||||
fields: draft.fields,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
(listing, "Listing created!")
|
||||
}
|
||||
ListingDraftPersisted::Persisted(fields) => {
|
||||
let listing = ListingDAO::update_listing(
|
||||
&db_pool,
|
||||
PersistedListing {
|
||||
persisted: fields,
|
||||
base: draft.base,
|
||||
fields: draft.fields,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
(listing, "Listing updated!")
|
||||
}
|
||||
};
|
||||
|
||||
let response = format!("✅ <b>{}</b>: {}", success_message, listing.base.title);
|
||||
send_message(&bot, target, response, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current value of a field for display
|
||||
fn get_current_field_value(
|
||||
draft: &ListingDraft,
|
||||
field: ListingField,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let value = match field {
|
||||
ListingField::Title => draft.base.title.clone(),
|
||||
ListingField::Description => draft
|
||||
.base
|
||||
.description
|
||||
.as_deref()
|
||||
.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"),
|
||||
},
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Get the edit keyboard for a field
|
||||
fn get_edit_keyboard_for_field(field: ListingField) -> InlineKeyboardMarkup {
|
||||
use crate::message_utils::create_single_button_keyboard;
|
||||
|
||||
let back_button = create_single_button_keyboard("🔙 Back", "edit_back");
|
||||
|
||||
match field {
|
||||
ListingField::Description => {
|
||||
let clear_button =
|
||||
create_single_button_keyboard("🧹 Clear description", "edit_clear_description");
|
||||
back_button.append_row(clear_button.inline_keyboard[0].clone())
|
||||
}
|
||||
ListingField::Slots => {
|
||||
back_button.append_row(SlotsKeyboardButtons::to_keyboard().inline_keyboard[0].clone())
|
||||
}
|
||||
ListingField::StartTime => back_button
|
||||
.append_row(StartTimeKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
|
||||
ListingField::Duration => back_button
|
||||
.append_row(DurationKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
|
||||
_ => back_button,
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,11 @@ keyboard_buttons! {
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum ConfirmationKeyboardButtons {
|
||||
Save("✅ Save", "confirm_save"),
|
||||
Create("✅ Create", "confirm_create"),
|
||||
Edit("✏️ Edit", "confirm_edit"),
|
||||
Discard("🗑️ Discard", "confirm_discard"),
|
||||
Cancel("❌ Cancel", "confirm_cancel"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
73
src/commands/new_listing/messages.rs
Normal file
73
src/commands/new_listing/messages.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Message constants and generation functions for the new listing wizard
|
||||
//!
|
||||
//! This module centralizes all user-facing messages to eliminate duplication
|
||||
//! 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::message_utils::*;
|
||||
use teloxide::types::InlineKeyboardMarkup;
|
||||
|
||||
// Step messages and responses - centralized to eliminate duplication
|
||||
const STEP_MESSAGES: [&str; 6] = [
|
||||
"<i>Step 1 of 6: Title</i>\nPlease enter a title for your listing (max 100 characters):",
|
||||
"<i>Step 2 of 6: Description</i>\nPlease enter a description for your listing (optional).",
|
||||
"<i>Step 3 of 6: Price</i>\nPlease enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n💡 <i>Price should be in USD</i>",
|
||||
"<i>Step 4 of 6: Available Slots</i>\nHow many items are available for sale?\n\nChoose a common value below or enter a custom number (1-1000):",
|
||||
"<i>Step 5 of 6: Start Time</i>\nWhen should your listing start?\n• Click 'Now' to start immediately\n• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)\n• Maximum delay: 168 hours (7 days)",
|
||||
"<i>Step 6 of 6: Duration</i>\nHow long should your listing run?\nEnter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):",
|
||||
];
|
||||
|
||||
const SUCCESS_MESSAGES: [&str; 6] = [
|
||||
"✅ Title saved!",
|
||||
"✅ Description saved!",
|
||||
"✅ Price saved!",
|
||||
"✅ Slots saved!",
|
||||
"✅ Start time saved!",
|
||||
"✅ Duration saved!",
|
||||
];
|
||||
|
||||
const EDIT_SUCCESS_MESSAGES: [&str; 6] = [
|
||||
"✅ Title updated!",
|
||||
"✅ Description updated!",
|
||||
"✅ Price updated!",
|
||||
"✅ Slots updated!",
|
||||
"✅ Start time updated!",
|
||||
"✅ Duration updated!",
|
||||
];
|
||||
|
||||
/// Get the step instruction message for a field
|
||||
pub fn get_step_message(field: ListingField) -> &'static str {
|
||||
STEP_MESSAGES[field as usize]
|
||||
}
|
||||
|
||||
/// Get the success message for completing a field
|
||||
pub fn get_success_message(field: ListingField) -> &'static str {
|
||||
SUCCESS_MESSAGES[field as usize]
|
||||
}
|
||||
|
||||
/// Get the success message for editing a field
|
||||
pub fn get_edit_success_message(field: ListingField) -> &'static str {
|
||||
EDIT_SUCCESS_MESSAGES[field as usize]
|
||||
}
|
||||
|
||||
/// Get the appropriate keyboard for a field
|
||||
pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarkup> {
|
||||
match field {
|
||||
ListingField::Title => Some(create_cancel_keyboard()),
|
||||
ListingField::Description => Some(create_skip_cancel_keyboard()),
|
||||
ListingField::Price => None,
|
||||
ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()),
|
||||
ListingField::StartTime => Some(StartTimeKeyboardButtons::to_keyboard()),
|
||||
ListingField::Duration => Some(DurationKeyboardButtons::to_keyboard()),
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard creation helpers
|
||||
fn create_cancel_keyboard() -> InlineKeyboardMarkup {
|
||||
create_single_button_keyboard("Cancel", "cancel")
|
||||
}
|
||||
|
||||
fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup {
|
||||
create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
159
src/commands/new_listing/tests.rs
Normal file
159
src/commands/new_listing/tests.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use crate::{
|
||||
commands::new_listing::{
|
||||
field_processing::process_field_update,
|
||||
types::{ListingDraft, ListingDraftPersisted, ListingField},
|
||||
},
|
||||
db::{
|
||||
listing::{FixedPriceListingFields, ListingFields, NewListingFields},
|
||||
ListingDuration, MoneyAmount, UserDbId,
|
||||
},
|
||||
};
|
||||
|
||||
fn create_test_draft() -> ListingDraft {
|
||||
ListingDraft {
|
||||
has_changes: false,
|
||||
persisted: ListingDraftPersisted::New(NewListingFields::default()),
|
||||
base: crate::db::listing::ListingBase {
|
||||
seller_id: UserDbId::new(1),
|
||||
title: "".to_string(),
|
||||
description: None,
|
||||
},
|
||||
fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
|
||||
buy_now_price: MoneyAmount::default(),
|
||||
slots_available: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// === Business Logic Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_complete_field_processing_workflow() {
|
||||
let mut draft = create_test_draft();
|
||||
|
||||
// Process all fields in sequence with realistic inputs
|
||||
let workflow = [
|
||||
(ListingField::Title, "Handcrafted Wooden Bowl"),
|
||||
(
|
||||
ListingField::Description,
|
||||
"Beautiful handmade oak bowl, perfect for serving",
|
||||
),
|
||||
(ListingField::Price, "34.99"),
|
||||
(ListingField::Slots, "2"),
|
||||
(ListingField::StartTime, "1"),
|
||||
(ListingField::Duration, "3 days"),
|
||||
];
|
||||
|
||||
for (field, input) in workflow {
|
||||
let result = process_field_update(field, &mut draft, input);
|
||||
assert!(result.is_ok(), "Processing {:?} should succeed", field);
|
||||
}
|
||||
|
||||
// Verify realistic final state
|
||||
assert_eq!(draft.base.title, "Handcrafted Wooden Bowl");
|
||||
assert!(draft
|
||||
.base
|
||||
.description
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("oak bowl"));
|
||||
|
||||
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
|
||||
assert_eq!(
|
||||
fields.buy_now_price,
|
||||
MoneyAmount::from_str("34.99").unwrap()
|
||||
);
|
||||
assert_eq!(fields.slots_available, 2);
|
||||
}
|
||||
|
||||
if let ListingDraftPersisted::New(fields) = &draft.persisted {
|
||||
assert_eq!(fields.start_delay, ListingDuration::hours(1));
|
||||
assert_eq!(fields.end_delay, ListingDuration::hours(72)); // 3 days
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = process_field_update(ListingField::StartTime, &mut draft, "5");
|
||||
assert!(
|
||||
start_time_result.is_err(),
|
||||
"Live listings cannot change start time"
|
||||
);
|
||||
|
||||
let duration_result = process_field_update(ListingField::Duration, &mut draft, "48");
|
||||
assert!(
|
||||
duration_result.is_err(),
|
||||
"Live listings cannot change duration"
|
||||
);
|
||||
|
||||
// But content can be updated
|
||||
let title_result = process_field_update(ListingField::Title, &mut draft, "Updated Title");
|
||||
assert!(title_result.is_ok(), "Live listings can update content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_natural_language_duration_conversion() {
|
||||
let mut draft = create_test_draft();
|
||||
|
||||
// Test critical natural language parsing works in business context
|
||||
let business_durations = [
|
||||
("1 day", 24), // Common short listing
|
||||
("7 days", 168), // Standard week-long listing
|
||||
("30 days", 720), // Maximum allowed duration
|
||||
];
|
||||
|
||||
for (input, expected_hours) in business_durations {
|
||||
process_field_update(ListingField::Duration, &mut draft, input).unwrap();
|
||||
|
||||
if let ListingDraftPersisted::New(fields) = &draft.persisted {
|
||||
assert_eq!(
|
||||
fields.end_delay,
|
||||
ListingDuration::hours(expected_hours),
|
||||
"Business duration '{}' should convert correctly",
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_price_and_slots_consistency_for_fixed_price_listings() {
|
||||
let mut draft = create_test_draft();
|
||||
|
||||
// Test that price and slots work together correctly for business logic
|
||||
process_field_update(ListingField::Price, &mut draft, "25.00").unwrap();
|
||||
process_field_update(ListingField::Slots, &mut draft, "5").unwrap();
|
||||
|
||||
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
|
||||
assert_eq!(
|
||||
fields.buy_now_price,
|
||||
MoneyAmount::from_str("25.00").unwrap()
|
||||
);
|
||||
assert_eq!(fields.slots_available, 5);
|
||||
|
||||
// Business logic: Total potential value is price * slots
|
||||
let total_value = fields.buy_now_price.cents() * fields.slots_available as i64;
|
||||
assert_eq!(total_value, 12500); // $25.00 * 5 = $125.00 (12500 cents)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::{
|
||||
db::{
|
||||
listing::{ListingBase, ListingFields, NewListingFields, PersistedListingFields},
|
||||
listing::{
|
||||
FixedPriceListingFields, ListingBase, ListingFields, NewListingFields,
|
||||
PersistedListing, PersistedListingFields,
|
||||
},
|
||||
MoneyAmount, UserDbId,
|
||||
},
|
||||
DialogueRootState,
|
||||
@@ -9,24 +12,35 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ListingDraft {
|
||||
pub has_changes: bool,
|
||||
pub persisted: ListingDraftPersisted,
|
||||
pub base: ListingBase,
|
||||
pub fields: ListingFields,
|
||||
}
|
||||
|
||||
impl ListingDraft {
|
||||
pub fn draft_for_seller(seller_id: UserDbId) -> Self {
|
||||
pub fn new_for_seller(seller_id: UserDbId) -> Self {
|
||||
Self {
|
||||
has_changes: false,
|
||||
persisted: ListingDraftPersisted::New(NewListingFields::default()),
|
||||
base: ListingBase {
|
||||
seller_id,
|
||||
title: "".to_string(),
|
||||
description: None,
|
||||
},
|
||||
fields: ListingFields::FixedPriceListing {
|
||||
fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
|
||||
buy_now_price: MoneyAmount::default(),
|
||||
slots_available: 0,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persisted(listing: PersistedListing) -> Self {
|
||||
Self {
|
||||
has_changes: false,
|
||||
persisted: ListingDraftPersisted::Persisted(listing.persisted),
|
||||
base: listing.base,
|
||||
fields: listing.fields,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
src/commands/new_listing/ui.rs
Normal file
102
src/commands/new_listing/ui.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! UI display functions for the new listing wizard
|
||||
//!
|
||||
//! This module handles all user interface display logic including
|
||||
//! listing summaries, confirmation screens, and edit interfaces.
|
||||
|
||||
use crate::{
|
||||
commands::new_listing::types::{ListingDraft, ListingDraftPersisted},
|
||||
db::listing::ListingFields,
|
||||
message_utils::*,
|
||||
HandlerResult,
|
||||
};
|
||||
use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons;
|
||||
use teloxide::{types::InlineKeyboardMarkup, Bot};
|
||||
|
||||
/// Display the listing summary with optional flash message and keyboard
|
||||
pub async fn display_listing_summary(
|
||||
bot: &Bot,
|
||||
target: impl Into<MessageTarget>,
|
||||
draft: &ListingDraft,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
flash_message: Option<&str>,
|
||||
) -> HandlerResult {
|
||||
let mut response_lines = vec![];
|
||||
|
||||
if let Some(flash_message) = flash_message {
|
||||
response_lines.push(flash_message.to_string());
|
||||
}
|
||||
|
||||
let unsaved_changes = if draft.has_changes {
|
||||
"<i>Unsaved changes</i>"
|
||||
} 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!(
|
||||
"📄 <b>Description:</b> {}",
|
||||
draft
|
||||
.base
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("<i>No description</i>")
|
||||
));
|
||||
|
||||
match &draft.fields {
|
||||
ListingFields::FixedPriceListing(fields) => {
|
||||
response_lines.push(format!(
|
||||
"💰 <b>Buy it Now Price:</b> ${}",
|
||||
fields.buy_now_price
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match &draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
response_lines.push(format!("<b>Start delay:</b> {}", fields.start_delay));
|
||||
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
|
||||
}
|
||||
ListingDraftPersisted::Persisted(fields) => {
|
||||
response_lines.push(format!(
|
||||
"<b>Starts on:</b> {}",
|
||||
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("Please review your listing and choose an action:".to_string());
|
||||
|
||||
send_message(&bot, target, response_lines.join("\n"), keyboard).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show the final confirmation screen before creating/saving the listing
|
||||
pub async fn show_confirmation_screen(
|
||||
bot: &Bot,
|
||||
target: impl Into<MessageTarget>,
|
||||
draft: &ListingDraft,
|
||||
) -> HandlerResult {
|
||||
let keyboard = match draft.persisted {
|
||||
ListingDraftPersisted::New(_) => 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::Edit.to_button(),
|
||||
ConfirmationKeyboardButtons::Cancel.to_button(),
|
||||
]),
|
||||
};
|
||||
|
||||
display_listing_summary(bot, target, draft, Some(keyboard), None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,7 +11,9 @@ use std::fmt::Debug;
|
||||
use crate::db::{
|
||||
bind_fields::BindFields,
|
||||
listing::{
|
||||
Listing, ListingBase, ListingFields, NewListing, PersistedListing, PersistedListingFields,
|
||||
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, Listing, ListingBase,
|
||||
ListingFields, MultiSlotAuctionFields, NewListing, PersistedListing,
|
||||
PersistedListingFields,
|
||||
},
|
||||
ListingDbId, ListingType, UserDbId,
|
||||
};
|
||||
@@ -157,40 +159,26 @@ fn binds_for_base(base: &ListingBase) -> BindFields {
|
||||
|
||||
fn binds_for_fields(fields: &ListingFields) -> BindFields {
|
||||
match fields {
|
||||
ListingFields::BasicAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
anti_snipe_minutes,
|
||||
} => BindFields::default()
|
||||
ListingFields::BasicAuction(fields) => BindFields::default()
|
||||
.push("listing_type", &ListingType::BasicAuction)
|
||||
.push("starting_bid", starting_bid)
|
||||
.push("buy_now_price", buy_now_price)
|
||||
.push("min_increment", min_increment)
|
||||
.push("anti_snipe_minutes", anti_snipe_minutes),
|
||||
ListingFields::MultiSlotAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
slots_available,
|
||||
anti_snipe_minutes,
|
||||
} => BindFields::default()
|
||||
.push("starting_bid", &fields.starting_bid)
|
||||
.push("buy_now_price", &fields.buy_now_price)
|
||||
.push("min_increment", &fields.min_increment)
|
||||
.push("anti_snipe_minutes", &fields.anti_snipe_minutes),
|
||||
ListingFields::MultiSlotAuction(fields) => BindFields::default()
|
||||
.push("listing_type", &ListingType::MultiSlotAuction)
|
||||
.push("starting_bid", starting_bid)
|
||||
.push("buy_now_price", buy_now_price)
|
||||
.push("min_increment", min_increment)
|
||||
.push("slots_available", slots_available)
|
||||
.push("anti_snipe_minutes", anti_snipe_minutes),
|
||||
ListingFields::FixedPriceListing {
|
||||
buy_now_price,
|
||||
slots_available,
|
||||
} => BindFields::default()
|
||||
.push("starting_bid", &fields.starting_bid)
|
||||
.push("buy_now_price", &fields.buy_now_price)
|
||||
.push("min_increment", &fields.min_increment)
|
||||
.push("slots_available", &fields.slots_available)
|
||||
.push("anti_snipe_minutes", &fields.anti_snipe_minutes),
|
||||
ListingFields::FixedPriceListing(fields) => BindFields::default()
|
||||
.push("listing_type", &ListingType::FixedPriceListing)
|
||||
.push("buy_now_price", buy_now_price)
|
||||
.push("slots_available", slots_available),
|
||||
ListingFields::BlindAuction { starting_bid } => BindFields::default()
|
||||
.push("buy_now_price", &fields.buy_now_price)
|
||||
.push("slots_available", &fields.slots_available),
|
||||
ListingFields::BlindAuction(fields) => BindFields::default()
|
||||
.push("listing_type", &ListingType::BlindAuction)
|
||||
.push("starting_bid", starting_bid),
|
||||
.push("starting_bid", &fields.starting_bid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,26 +198,30 @@ impl FromRow<'_, SqliteRow> for PersistedListing {
|
||||
description: row.get("description"),
|
||||
};
|
||||
let fields = match listing_type {
|
||||
ListingType::BasicAuction => ListingFields::BasicAuction {
|
||||
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
min_increment: row.get("min_increment"),
|
||||
anti_snipe_minutes: row.get("anti_snipe_minutes"),
|
||||
},
|
||||
ListingType::MultiSlotAuction => ListingFields::MultiSlotAuction {
|
||||
}),
|
||||
ListingType::MultiSlotAuction => {
|
||||
ListingFields::MultiSlotAuction(MultiSlotAuctionFields {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
min_increment: row.get("min_increment"),
|
||||
slots_available: row.get("slots_available"),
|
||||
anti_snipe_minutes: row.get("anti_snipe_minutes"),
|
||||
})
|
||||
}
|
||||
ListingType::FixedPriceListing => {
|
||||
ListingFields::FixedPriceListing(FixedPriceListingFields {
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
slots_available: row.get("slots_available"),
|
||||
})
|
||||
}
|
||||
ListingType::BlindAuction => ListingFields::BlindAuction(BlindAuctionFields {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
min_increment: row.get("min_increment"),
|
||||
slots_available: row.get("slots_available"),
|
||||
anti_snipe_minutes: row.get("anti_snipe_minutes"),
|
||||
},
|
||||
ListingType::FixedPriceListing => ListingFields::FixedPriceListing {
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
slots_available: row.get("slots_available"),
|
||||
},
|
||||
ListingType::BlindAuction => ListingFields::BlindAuction {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
},
|
||||
}),
|
||||
};
|
||||
Ok(PersistedListing {
|
||||
persisted,
|
||||
|
||||
@@ -89,27 +89,34 @@ impl UserDAO {
|
||||
pool: &SqlitePool,
|
||||
user: teloxide::types::User,
|
||||
) -> Result<PersistedUser> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let telegram_id = TelegramUserDbId::from(user.id);
|
||||
let binds = BindFields::default()
|
||||
.push("telegram_id", &TelegramUserDbId::from(user.id))
|
||||
.push("username", &user.username)
|
||||
.push("first_name", &user.first_name)
|
||||
.push("last_name", &user.last_name);
|
||||
|
||||
let user = sqlx::query_as(
|
||||
let query_str = format!(
|
||||
r#"
|
||||
INSERT INTO users (telegram_id, username, first_name, last_name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO users ({})
|
||||
VALUES ({})
|
||||
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name
|
||||
RETURNING id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at
|
||||
RETURNING {}
|
||||
"#,
|
||||
)
|
||||
.bind(telegram_id)
|
||||
.bind(user.username)
|
||||
.bind(user.first_name)
|
||||
.bind(user.last_name)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
binds.bind_names().join(", "),
|
||||
binds.bind_placeholders().join(", "),
|
||||
USER_RETURN_FIELDS.join(", ")
|
||||
);
|
||||
|
||||
let row = binds
|
||||
.bind_to_query(sqlx::query(&query_str))
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let user = FromRow::from_row(&row)?;
|
||||
log::info!("load user from db: {:?}", user);
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
@@ -366,4 +373,145 @@ mod tests {
|
||||
|
||||
assert!(not_found_by_telegram.is_none());
|
||||
}
|
||||
|
||||
mod upsert_tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case(None, None, None)]
|
||||
#[case(Some("new_user"), None, None)]
|
||||
#[case(None, Some("New First"), None)]
|
||||
#[case(None, None, Some("New Last"))]
|
||||
#[case(Some(""), None, Some(""))]
|
||||
#[tokio::test]
|
||||
async fn test_upsert_updates_fields(
|
||||
#[case] username: Option<&str>,
|
||||
#[case] first_name: Option<&str>,
|
||||
#[case] last_name: Option<&str>,
|
||||
) {
|
||||
let pool = create_test_pool().await;
|
||||
let user_id = UserId(12345);
|
||||
|
||||
let initial = teloxide::types::User {
|
||||
id: user_id,
|
||||
is_bot: false,
|
||||
first_name: "First".into(),
|
||||
last_name: Some("Last".into()),
|
||||
username: Some("user".into()),
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
|
||||
let created = UserDAO::find_or_create_by_telegram_user(&pool, initial)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut updated = teloxide::types::User {
|
||||
id: user_id,
|
||||
is_bot: false,
|
||||
first_name: "First".into(),
|
||||
last_name: Some("Last".into()),
|
||||
username: Some("user".into()),
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
if let Some(u) = username {
|
||||
updated.username = if u.is_empty() { None } else { Some(u.into()) };
|
||||
}
|
||||
if let Some(f) = first_name {
|
||||
updated.first_name = f.into();
|
||||
}
|
||||
if let Some(l) = last_name {
|
||||
updated.last_name = if l.is_empty() { None } else { Some(l.into()) };
|
||||
}
|
||||
|
||||
let result = UserDAO::find_or_create_by_telegram_user(&pool, updated.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.persisted.id, result.persisted.id);
|
||||
assert_eq!(result.username, updated.username);
|
||||
assert_eq!(result.first_name, updated.first_name);
|
||||
assert_eq!(result.last_name, updated.last_name);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_users_separate() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let user1 = teloxide::types::User {
|
||||
id: UserId(111),
|
||||
is_bot: false,
|
||||
first_name: "One".into(),
|
||||
last_name: None,
|
||||
username: Some("one".into()),
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
let user2 = teloxide::types::User {
|
||||
id: UserId(222),
|
||||
is_bot: false,
|
||||
first_name: "Two".into(),
|
||||
last_name: Some("Last".into()),
|
||||
username: None,
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
|
||||
let p1 = UserDAO::find_or_create_by_telegram_user(&pool, user1)
|
||||
.await
|
||||
.unwrap();
|
||||
let p2 = UserDAO::find_or_create_by_telegram_user(&pool, user2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(p1.persisted.id, p2.persisted.id);
|
||||
assert_eq!(p1.telegram_id, UserId(111).into());
|
||||
assert_eq!(p2.telegram_id, UserId(222).into());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upsert_preserves_id_and_timestamps() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let user = teloxide::types::User {
|
||||
id: UserId(333),
|
||||
is_bot: false,
|
||||
first_name: "Original".into(),
|
||||
last_name: None,
|
||||
username: Some("orig".into()),
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
|
||||
let created = UserDAO::find_or_create_by_telegram_user(&pool, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated_user = teloxide::types::User {
|
||||
id: UserId(333),
|
||||
is_bot: false,
|
||||
first_name: "Original".into(),
|
||||
last_name: None,
|
||||
username: Some("updated".into()),
|
||||
language_code: None,
|
||||
is_premium: false,
|
||||
added_to_attachment_menu: false,
|
||||
};
|
||||
|
||||
let updated = UserDAO::find_or_create_by_telegram_user(&pool, updated_user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.persisted.id, updated.persisted.id);
|
||||
assert_eq!(created.persisted.created_at, updated.persisted.created_at);
|
||||
assert_eq!(updated.username, Some("updated".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,35 +61,54 @@ impl ListingBase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields specific to basic auction listings
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct BasicAuctionFields {
|
||||
pub starting_bid: MoneyAmount,
|
||||
pub buy_now_price: Option<MoneyAmount>,
|
||||
pub min_increment: MoneyAmount,
|
||||
pub anti_snipe_minutes: Option<i32>,
|
||||
}
|
||||
|
||||
/// Fields specific to multi-slot auction listings
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct MultiSlotAuctionFields {
|
||||
pub starting_bid: MoneyAmount,
|
||||
pub buy_now_price: MoneyAmount,
|
||||
pub min_increment: Option<MoneyAmount>,
|
||||
pub slots_available: i32,
|
||||
pub anti_snipe_minutes: i32,
|
||||
}
|
||||
|
||||
/// Fields specific to fixed price listings
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct FixedPriceListingFields {
|
||||
pub buy_now_price: MoneyAmount,
|
||||
pub slots_available: i32,
|
||||
}
|
||||
|
||||
/// Fields specific to blind auction listings
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct BlindAuctionFields {
|
||||
pub starting_bid: MoneyAmount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub enum ListingFields {
|
||||
BasicAuction {
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
anti_snipe_minutes: Option<i32>,
|
||||
},
|
||||
MultiSlotAuction {
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: MoneyAmount,
|
||||
min_increment: Option<MoneyAmount>,
|
||||
slots_available: i32,
|
||||
anti_snipe_minutes: i32,
|
||||
},
|
||||
FixedPriceListing {
|
||||
buy_now_price: MoneyAmount,
|
||||
slots_available: i32,
|
||||
},
|
||||
BlindAuction {
|
||||
starting_bid: MoneyAmount,
|
||||
},
|
||||
BasicAuction(BasicAuctionFields),
|
||||
MultiSlotAuction(MultiSlotAuctionFields),
|
||||
FixedPriceListing(FixedPriceListingFields),
|
||||
BlindAuction(BlindAuctionFields),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::ListingDbId;
|
||||
use crate::db::{ListingDAO, TelegramUserDbId};
|
||||
use rstest::rstest;
|
||||
use sqlx::SqlitePool;
|
||||
@@ -152,11 +171,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })]
|
||||
#[case(ListingFields::BasicAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), min_increment: MoneyAmount::from_str("1.00").unwrap(), anti_snipe_minutes: Some(5) })]
|
||||
#[case(ListingFields::MultiSlotAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: MoneyAmount::from_str("100.00").unwrap(), min_increment: Some(MoneyAmount::from_str("1.00").unwrap()), slots_available: 10, anti_snipe_minutes: 5 })]
|
||||
#[case(ListingFields::FixedPriceListing { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 })]
|
||||
#[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })]
|
||||
#[case(ListingFields::BlindAuction(BlindAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap() }))]
|
||||
#[case(ListingFields::BasicAuction(BasicAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), min_increment: MoneyAmount::from_str("1.00").unwrap(), anti_snipe_minutes: Some(5) }))]
|
||||
#[case(ListingFields::MultiSlotAuction(MultiSlotAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: MoneyAmount::from_str("100.00").unwrap(), min_increment: Some(MoneyAmount::from_str("1.00").unwrap()), slots_available: 10, anti_snipe_minutes: 5 }))]
|
||||
#[case(ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 }))]
|
||||
#[tokio::test]
|
||||
async fn test_blind_auction_crud(#[case] fields: ListingFields) {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct User<P: Debug + Clone> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub struct PersistedUserFields {
|
||||
pub id: UserDbId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
@@ -34,6 +34,13 @@ macro_rules! keyboard_buttons {
|
||||
)*
|
||||
markup
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn to_button(self) -> teloxide::types::InlineKeyboardButton {
|
||||
match self {
|
||||
$($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<$name> for teloxide::types::InlineKeyboardButton {
|
||||
fn from(value: $name) -> Self {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::HandlerResult;
|
||||
use anyhow::bail;
|
||||
use chrono::{DateTime, Utc};
|
||||
use num::One;
|
||||
use std::fmt::Display;
|
||||
use teloxide::{
|
||||
@@ -209,3 +210,7 @@ pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
|
||||
) -> String {
|
||||
format!("{} {}", count, pluralize(count, singular, plural))
|
||||
}
|
||||
|
||||
pub fn format_datetime(dt: DateTime<Utc>) -> String {
|
||||
dt.format("%b %d, %Y %H:%M UTC").to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user