refactor: Update listing confirmation flow and summary display
- Consolidate Create and Save button handling in confirmation flow - Add unsaved changes indicator to listing summary - Improve edit flow by using enter_edit_listing_draft function - Update confirmation button logic and user feedback messages - Clean up dialogue state management in confirmation handlers
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
case,
|
case,
|
||||||
|
commands::new_listing::{enter_edit_listing_draft, ListingDraft},
|
||||||
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
|
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
|
||||||
keyboard_buttons,
|
keyboard_buttons,
|
||||||
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
|
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
|
||||||
@@ -18,7 +19,6 @@ use teloxide::{
|
|||||||
pub enum MyListingsState {
|
pub enum MyListingsState {
|
||||||
ViewingListings,
|
ViewingListings,
|
||||||
ManagingListing(ListingDbId),
|
ManagingListing(ListingDbId),
|
||||||
EditingListing(ListingDbId),
|
|
||||||
}
|
}
|
||||||
impl From<MyListingsState> for DialogueRootState {
|
impl From<MyListingsState> for DialogueRootState {
|
||||||
fn from(state: MyListingsState) -> Self {
|
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\
|
"📋 <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")
|
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?;
|
send_message(&bot, target, response, Some(keyboard)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -169,15 +161,13 @@ async fn show_listing_details(
|
|||||||
let response = format!(
|
let response = format!(
|
||||||
"🔍 <b>Viewing Listing Details</b>\n\n\
|
"🔍 <b>Viewing Listing Details</b>\n\n\
|
||||||
<b>Title:</b> {}\n\
|
<b>Title:</b> {}\n\
|
||||||
<b>Description:</b> {}\n\
|
<b>Description:</b> {}\n",
|
||||||
<b>ID:</b> {}",
|
|
||||||
listing.base.title,
|
listing.base.title,
|
||||||
listing
|
listing
|
||||||
.base
|
.base
|
||||||
.description
|
.description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("No description"),
|
.unwrap_or("No description"),
|
||||||
listing.persisted.id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
send_message(
|
send_message(
|
||||||
@@ -207,9 +197,8 @@ async fn handle_managing_listing_callback(
|
|||||||
ManageListingButtons::Edit => {
|
ManageListingButtons::Edit => {
|
||||||
let (_, listing) =
|
let (_, listing) =
|
||||||
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
|
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
|
||||||
dialogue
|
let draft = ListingDraft::from_persisted(listing);
|
||||||
.update(MyListingsState::EditingListing(listing.persisted.id))
|
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
ManageListingButtons::Delete => {
|
ManageListingButtons::Delete => {
|
||||||
ListingDAO::delete_listing(&db_pool, listing_id).await?;
|
ListingDAO::delete_listing(&db_pool, listing_id).await?;
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ keyboard_buttons! {
|
|||||||
|
|
||||||
keyboard_buttons! {
|
keyboard_buttons! {
|
||||||
pub enum ConfirmationKeyboardButtons {
|
pub enum ConfirmationKeyboardButtons {
|
||||||
|
Save("✅ Save", "confirm_save"),
|
||||||
Create("✅ Create", "confirm_create"),
|
Create("✅ Create", "confirm_create"),
|
||||||
Edit("✏️ Edit", "confirm_edit"),
|
Edit("✏️ Edit", "confirm_edit"),
|
||||||
Discard("🗑️ Discard", "confirm_discard"),
|
Discard("🗑️ Discard", "confirm_discard"),
|
||||||
|
Cancel("❌ Cancel", "confirm_cancel"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async fn handle_new_listing_command(
|
|||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::AwaitingDraftField {
|
.update(NewListingState::AwaitingDraftField {
|
||||||
field: ListingField::Title,
|
field: ListingField::Title,
|
||||||
draft: ListingDraft::draft_for_seller(user.persisted.id),
|
draft: ListingDraft::new_for_seller(user.persisted.id),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ async fn handle_new_listing_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_awaiting_draft_field_input(
|
async fn handle_awaiting_draft_field_input(
|
||||||
bot: &Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
(field, draft): (ListingField, ListingDraft),
|
(field, draft): (ListingField, ListingDraft),
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -90,18 +90,18 @@ async fn handle_awaiting_draft_field_input(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if is_cancel(text) {
|
if is_cancel(text) {
|
||||||
return cancel_wizard(bot, dialogue, chat).await;
|
return cancel_wizard(&bot, dialogue, chat).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
ListingField::Title => handle_title_input(bot, chat, text, dialogue, draft).await,
|
ListingField::Title => handle_title_input(&bot, chat, text, dialogue, draft).await,
|
||||||
ListingField::Description => {
|
ListingField::Description => {
|
||||||
handle_description_input(bot, chat, text, dialogue, draft).await
|
handle_description_input(&bot, chat, text, dialogue, draft).await
|
||||||
}
|
}
|
||||||
ListingField::Price => handle_price_input(bot, chat, text, dialogue, draft).await,
|
ListingField::Price => handle_price_input(&bot, chat, text, dialogue, draft).await,
|
||||||
ListingField::Slots => handle_slots_input(bot, chat, text, dialogue, draft).await,
|
ListingField::Slots => handle_slots_input(&bot, chat, text, dialogue, draft).await,
|
||||||
ListingField::StartTime => handle_start_time_input(bot, chat, text, dialogue, draft).await,
|
ListingField::StartTime => handle_start_time_input(&bot, chat, text, dialogue, draft).await,
|
||||||
ListingField::Duration => handle_duration_input(bot, chat, text, dialogue, draft).await,
|
ListingField::Duration => handle_duration_input(&bot, chat, text, dialogue, draft).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ async fn handle_description_callback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_awaiting_draft_field_callback(
|
async fn handle_awaiting_draft_field_callback(
|
||||||
bot: &Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
(field, draft): (ListingField, ListingDraft),
|
(field, draft): (ListingField, ListingDraft),
|
||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
@@ -206,7 +206,7 @@ async fn handle_awaiting_draft_field_callback(
|
|||||||
let target = (from, message_id);
|
let target = (from, message_id);
|
||||||
|
|
||||||
if data == "cancel" {
|
if data == "cancel" {
|
||||||
return cancel_wizard(bot, dialogue, target).await;
|
return cancel_wizard(&bot, dialogue, target).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
@@ -216,7 +216,7 @@ async fn handle_awaiting_draft_field_callback(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
ListingField::Description => {
|
ListingField::Description => {
|
||||||
handle_description_callback(bot, dialogue, draft, data.as_str(), target).await
|
handle_description_callback(&bot, dialogue, draft, data.as_str(), target).await
|
||||||
}
|
}
|
||||||
ListingField::Price => {
|
ListingField::Price => {
|
||||||
error!("Unknown callback data: {data}");
|
error!("Unknown callback data: {data}");
|
||||||
@@ -224,13 +224,13 @@ async fn handle_awaiting_draft_field_callback(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
ListingField::Slots => {
|
ListingField::Slots => {
|
||||||
handle_slots_callback(bot, dialogue, draft, data.as_str(), target).await
|
handle_slots_callback(&bot, dialogue, draft, data.as_str(), target).await
|
||||||
}
|
}
|
||||||
ListingField::StartTime => {
|
ListingField::StartTime => {
|
||||||
handle_start_time_callback(bot, dialogue, draft, data.as_str(), target).await
|
handle_start_time_callback(&bot, dialogue, draft, data.as_str(), target).await
|
||||||
}
|
}
|
||||||
ListingField::Duration => {
|
ListingField::Duration => {
|
||||||
handle_duration_callback(bot, dialogue, draft, data.as_str(), target).await
|
handle_duration_callback(&bot, dialogue, draft, data.as_str(), target).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,39 +331,44 @@ async fn handle_viewing_draft_callback(
|
|||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||||
let target = (from, message_id);
|
|
||||||
|
// 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())
|
let button = ConfirmationKeyboardButtons::try_from(data.as_str())
|
||||||
.map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?;
|
.map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?;
|
||||||
|
|
||||||
match button {
|
match button {
|
||||||
ConfirmationKeyboardButtons::Create => {
|
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
|
||||||
info!("User {target:?} confirmed listing creation");
|
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?;
|
dialogue.exit().await?;
|
||||||
save_listing(db_pool, bot, dialogue, target, draft).await?;
|
|
||||||
}
|
}
|
||||||
ConfirmationKeyboardButtons::Discard => {
|
ConfirmationKeyboardButtons::Discard => {
|
||||||
info!("User {target:?} discarded listing creation");
|
info!("User {target:?} discarded listing creation");
|
||||||
|
|
||||||
// Exit dialogue and send cancellation message
|
|
||||||
dialogue.exit().await?;
|
|
||||||
|
|
||||||
let response = "🗑️ <b>Listing Discarded</b>\n\n\
|
let response = "🗑️ <b>Listing Discarded</b>\n\n\
|
||||||
Your listing has been discarded and not created.\n\
|
Your listing has been discarded and not created.\n\
|
||||||
You can start a new listing anytime with /newlisting.";
|
You can start a new listing anytime with /newlisting.";
|
||||||
|
|
||||||
send_message(&bot, target, &response, None).await?;
|
send_message(&bot, target, &response, None).await?;
|
||||||
|
dialogue.exit().await?;
|
||||||
}
|
}
|
||||||
ConfirmationKeyboardButtons::Edit => {
|
ConfirmationKeyboardButtons::Edit => {
|
||||||
info!("User {target:?} chose to edit listing");
|
info!("User {target:?} chose to edit listing");
|
||||||
|
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||||
// Delete the old message and show the edit screen
|
|
||||||
show_edit_screen(&bot, target, &draft, None).await?;
|
|
||||||
|
|
||||||
// Go to editing state to allow user to modify specific fields
|
|
||||||
dialogue
|
|
||||||
.update(NewListingState::EditingDraft(draft))
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +588,12 @@ async fn display_listing_summary(
|
|||||||
response_lines.push(flash_message.to_string());
|
response_lines.push(flash_message.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
response_lines.push("📋 <i><b>Listing Summary</b></i>".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("".to_string());
|
||||||
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
|
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
|
||||||
response_lines.push(format!(
|
response_lines.push(format!(
|
||||||
@@ -608,8 +618,14 @@ async fn display_listing_summary(
|
|||||||
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
|
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
|
||||||
}
|
}
|
||||||
ListingDraftPersisted::Persisted(fields) => {
|
ListingDraftPersisted::Persisted(fields) => {
|
||||||
response_lines.push(format!("<b>Starts on:</b> {}", fields.start_at));
|
response_lines.push(format!(
|
||||||
response_lines.push(format!("<b>Ends on:</b> {}", fields.end_at));
|
"<b>Starts on:</b> {}",
|
||||||
|
format_datetime(fields.start_at)
|
||||||
|
));
|
||||||
|
response_lines.push(format!(
|
||||||
|
"<b>Ends on:</b> {}",
|
||||||
|
format_datetime(fields.end_at)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,20 +637,25 @@ async fn display_listing_summary(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_edit_screen(
|
pub async fn enter_edit_listing_draft(
|
||||||
bot: &Bot,
|
bot: &Bot,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
draft: &ListingDraft,
|
draft: ListingDraft,
|
||||||
|
dialogue: RootDialogue,
|
||||||
flash_message: Option<&str>,
|
flash_message: Option<&str>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
display_listing_summary(
|
display_listing_summary(
|
||||||
bot,
|
bot,
|
||||||
target,
|
target,
|
||||||
draft,
|
&draft,
|
||||||
Some(FieldSelectionKeyboardButtons::to_keyboard()),
|
Some(FieldSelectionKeyboardButtons::to_keyboard()),
|
||||||
flash_message,
|
flash_message,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
dialogue
|
||||||
|
.update(NewListingState::EditingDraft(draft))
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,19 +664,25 @@ async fn show_confirmation_screen(
|
|||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
draft: &ListingDraft,
|
draft: &ListingDraft,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
display_listing_summary(
|
let keyboard = match draft.persisted {
|
||||||
bot,
|
ListingDraftPersisted::New(_) => InlineKeyboardMarkup::default().append_row([
|
||||||
target,
|
ConfirmationKeyboardButtons::Create.to_button(),
|
||||||
draft,
|
ConfirmationKeyboardButtons::Edit.to_button(),
|
||||||
Some(ConfirmationKeyboardButtons::to_keyboard()),
|
ConfirmationKeyboardButtons::Discard.to_button(),
|
||||||
None,
|
]),
|
||||||
)
|
ListingDraftPersisted::Persisted(_) => InlineKeyboardMarkup::default().append_row([
|
||||||
.await?;
|
ConfirmationKeyboardButtons::Save.to_button(),
|
||||||
|
ConfirmationKeyboardButtons::Edit.to_button(),
|
||||||
|
ConfirmationKeyboardButtons::Cancel.to_button(),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
display_listing_summary(bot, target, draft, Some(keyboard), None).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_editing_field_input(
|
async fn handle_editing_field_input(
|
||||||
bot: &Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
(field, draft): (ListingField, ListingDraft),
|
(field, draft): (ListingField, ListingDraft),
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -667,22 +694,22 @@ async fn handle_editing_field_input(
|
|||||||
|
|
||||||
match field {
|
match field {
|
||||||
ListingField::Title => {
|
ListingField::Title => {
|
||||||
handle_edit_title(bot, dialogue, draft, text, chat).await?;
|
handle_edit_title(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
ListingField::Description => {
|
ListingField::Description => {
|
||||||
handle_edit_description(bot, dialogue, draft, text, chat).await?;
|
handle_edit_description(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
ListingField::Price => {
|
ListingField::Price => {
|
||||||
handle_edit_price(bot, dialogue, draft, text, chat).await?;
|
handle_edit_price(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
ListingField::Slots => {
|
ListingField::Slots => {
|
||||||
handle_edit_slots(bot, dialogue, draft, text, chat).await?;
|
handle_edit_slots(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
ListingField::StartTime => {
|
ListingField::StartTime => {
|
||||||
handle_edit_start_time(bot, dialogue, draft, text, chat).await?;
|
handle_edit_start_time(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
ListingField::Duration => {
|
ListingField::Duration => {
|
||||||
handle_edit_duration(bot, dialogue, draft, text, chat).await?;
|
handle_edit_duration(&bot, dialogue, draft, text, chat).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,7 +717,7 @@ async fn handle_editing_field_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_editing_draft_callback(
|
async fn handle_editing_draft_callback(
|
||||||
bot: &Bot,
|
bot: Bot,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
@@ -759,7 +786,7 @@ async fn handle_editing_draft_callback(
|
|||||||
create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()),
|
create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()),
|
||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::Done => {
|
FieldSelectionKeyboardButtons::Done => {
|
||||||
show_confirmation_screen(bot, target, &draft).await?;
|
show_confirmation_screen(&bot, target, &draft).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::ViewingDraft(draft),
|
NewListingState::ViewingDraft(draft),
|
||||||
@@ -790,13 +817,12 @@ async fn handle_editing_draft_callback(
|
|||||||
async fn save_listing(
|
async fn save_listing(
|
||||||
db_pool: SqlitePool,
|
db_pool: SqlitePool,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let listing: PersistedListing = match draft.persisted {
|
let (listing, success_message) = match draft.persisted {
|
||||||
ListingDraftPersisted::New(fields) => {
|
ListingDraftPersisted::New(fields) => {
|
||||||
ListingDAO::insert_listing(
|
let listing = ListingDAO::insert_listing(
|
||||||
&db_pool,
|
&db_pool,
|
||||||
NewListing {
|
NewListing {
|
||||||
persisted: fields,
|
persisted: fields,
|
||||||
@@ -804,10 +830,11 @@ async fn save_listing(
|
|||||||
fields: draft.fields,
|
fields: draft.fields,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
|
(listing, "Listing created!")
|
||||||
}
|
}
|
||||||
ListingDraftPersisted::Persisted(fields) => {
|
ListingDraftPersisted::Persisted(fields) => {
|
||||||
ListingDAO::update_listing(
|
let listing = ListingDAO::update_listing(
|
||||||
&db_pool,
|
&db_pool,
|
||||||
PersistedListing {
|
PersistedListing {
|
||||||
persisted: fields,
|
persisted: fields,
|
||||||
@@ -815,21 +842,13 @@ async fn save_listing(
|
|||||||
fields: draft.fields,
|
fields: draft.fields,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
|
(listing, "Listing updated!")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = format!(
|
let response = format!("✅ <b>{}</b>: {}", success_message, listing.base.title);
|
||||||
"✅ <b>Listing Created Successfully!</b>\n\n\
|
|
||||||
<b>Listing ID:</b> {}\n\
|
|
||||||
<b>Title:</b> {}\n\
|
|
||||||
Your fixed price listing is now live! 🎉",
|
|
||||||
listing.persisted.id, listing.base.title
|
|
||||||
);
|
|
||||||
|
|
||||||
dialogue.exit().await?;
|
|
||||||
send_message(&bot, target, response, None).await?;
|
send_message(&bot, target, response, None).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,14 +876,8 @@ async fn handle_edit_title(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
draft.has_changes = true;
|
||||||
// Go back to editing listing state
|
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Title updated!")).await?;
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Title updated!")).await?;
|
|
||||||
dialogue
|
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,14 +898,16 @@ async fn handle_edit_description(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
draft.has_changes = true;
|
||||||
|
|
||||||
// Go back to editing listing state
|
enter_edit_listing_draft(
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Description updated!")).await?;
|
bot,
|
||||||
dialogue
|
target,
|
||||||
.update(DialogueRootState::NewListing(
|
draft,
|
||||||
NewListingState::EditingDraft(draft),
|
dialogue,
|
||||||
))
|
Some("✅ Description updated!"),
|
||||||
.await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,15 +934,9 @@ async fn handle_edit_price(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
draft.has_changes = true;
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Price updated!")).await?;
|
|
||||||
|
|
||||||
dialogue
|
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
|
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Price updated!")).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,13 +965,9 @@ async fn handle_edit_slots(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Slots updated!")).await?;
|
draft.has_changes = true;
|
||||||
dialogue
|
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
|
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Slots updated!")).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,13 +994,9 @@ async fn handle_edit_start_time(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
draft.has_changes = true;
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Start time updated!")).await?;
|
|
||||||
dialogue
|
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Start time updated!")).await?;
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,18 +1022,14 @@ async fn handle_edit_duration(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
draft.has_changes = true;
|
||||||
|
|
||||||
show_edit_screen(bot, target, &draft, Some("✅ Duration updated!")).await?;
|
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Duration updated!")).await?;
|
||||||
dialogue
|
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_editing_draft_field_callback(
|
async fn handle_editing_draft_field_callback(
|
||||||
bot: &Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
(field, draft): (ListingField, ListingDraft),
|
(field, draft): (ListingField, ListingDraft),
|
||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
@@ -1041,33 +1038,28 @@ async fn handle_editing_draft_field_callback(
|
|||||||
let target = (from, message_id);
|
let target = (from, message_id);
|
||||||
info!("User {:?} editing field: {:?} -> {}", target, field, &data);
|
info!("User {:?} editing field: {:?} -> {}", target, field, &data);
|
||||||
if data == "edit_back" {
|
if data == "edit_back" {
|
||||||
show_edit_screen(bot, target, &draft, None).await?;
|
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
|
||||||
dialogue
|
|
||||||
.update(DialogueRootState::NewListing(
|
|
||||||
NewListingState::EditingDraft(draft),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
ListingField::Title => {
|
ListingField::Title => {
|
||||||
handle_edit_title(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_title(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
ListingField::Description => {
|
ListingField::Description => {
|
||||||
handle_edit_description(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_description(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
ListingField::Price => {
|
ListingField::Price => {
|
||||||
handle_edit_price(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_price(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
ListingField::Slots => {
|
ListingField::Slots => {
|
||||||
handle_edit_slots(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_slots(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
ListingField::StartTime => {
|
ListingField::StartTime => {
|
||||||
handle_edit_start_time(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_start_time(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
ListingField::Duration => {
|
ListingField::Duration => {
|
||||||
handle_edit_duration(bot, dialogue, draft, data.as_str(), target).await?;
|
handle_edit_duration(&bot, dialogue, draft, data.as_str(), target).await?;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
listing::{ListingBase, ListingFields, NewListingFields, PersistedListingFields},
|
listing::{
|
||||||
|
ListingBase, ListingFields, NewListingFields, PersistedListing, PersistedListingFields,
|
||||||
|
},
|
||||||
MoneyAmount, UserDbId,
|
MoneyAmount, UserDbId,
|
||||||
},
|
},
|
||||||
DialogueRootState,
|
DialogueRootState,
|
||||||
@@ -9,14 +11,16 @@ 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 persisted: ListingDraftPersisted,
|
pub persisted: ListingDraftPersisted,
|
||||||
pub base: ListingBase,
|
pub base: ListingBase,
|
||||||
pub fields: ListingFields,
|
pub fields: ListingFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListingDraft {
|
impl ListingDraft {
|
||||||
pub fn draft_for_seller(seller_id: UserDbId) -> Self {
|
pub fn new_for_seller(seller_id: UserDbId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
has_changes: false,
|
||||||
persisted: ListingDraftPersisted::New(NewListingFields::default()),
|
persisted: ListingDraftPersisted::New(NewListingFields::default()),
|
||||||
base: ListingBase {
|
base: ListingBase {
|
||||||
seller_id,
|
seller_id,
|
||||||
@@ -29,6 +33,15 @@ impl ListingDraft {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_persisted(listing: PersistedListing) -> Self {
|
||||||
|
Self {
|
||||||
|
has_changes: false,
|
||||||
|
persisted: ListingDraftPersisted::Persisted(listing.persisted),
|
||||||
|
base: listing.base,
|
||||||
|
fields: listing.fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -89,27 +89,34 @@ impl UserDAO {
|
|||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
user: teloxide::types::User,
|
user: teloxide::types::User,
|
||||||
) -> Result<PersistedUser> {
|
) -> Result<PersistedUser> {
|
||||||
let mut tx = pool.begin().await?;
|
let binds = BindFields::default()
|
||||||
let telegram_id = TelegramUserDbId::from(user.id);
|
.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#"
|
r#"
|
||||||
INSERT INTO users (telegram_id, username, first_name, last_name)
|
INSERT INTO users ({})
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES ({})
|
||||||
ON CONFLICT (telegram_id) DO UPDATE SET
|
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username,
|
||||||
first_name = EXCLUDED.first_name,
|
first_name = EXCLUDED.first_name,
|
||||||
last_name = EXCLUDED.last_name
|
last_name = EXCLUDED.last_name
|
||||||
RETURNING id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at
|
RETURNING {}
|
||||||
"#,
|
"#,
|
||||||
)
|
binds.bind_names().join(", "),
|
||||||
.bind(telegram_id)
|
binds.bind_placeholders().join(", "),
|
||||||
.bind(user.username)
|
USER_RETURN_FIELDS.join(", ")
|
||||||
.bind(user.first_name)
|
);
|
||||||
.bind(user.last_name)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
|
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)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,4 +373,145 @@ mod tests {
|
|||||||
|
|
||||||
assert!(not_found_by_telegram.is_none());
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ pub enum ListingFields {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::ListingDbId;
|
|
||||||
use crate::db::{ListingDAO, TelegramUserDbId};
|
use crate::db::{ListingDAO, TelegramUserDbId};
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct User<P: Debug + Clone> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(unused)]
|
||||||
pub struct PersistedUserFields {
|
pub struct PersistedUserFields {
|
||||||
pub id: UserDbId,
|
pub id: UserDbId,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ macro_rules! keyboard_buttons {
|
|||||||
)*
|
)*
|
||||||
markup
|
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 {
|
impl From<$name> for teloxide::types::InlineKeyboardButton {
|
||||||
fn from(value: $name) -> Self {
|
fn from(value: $name) -> Self {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::HandlerResult;
|
use crate::HandlerResult;
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use num::One;
|
use num::One;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
@@ -209,3 +210,7 @@ pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
|
|||||||
) -> String {
|
) -> String {
|
||||||
format!("{} {}", count, pluralize(count, singular, plural))
|
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