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:
Dylan Knutson
2025-08-30 01:29:29 +00:00
parent 8fb51d12a7
commit 143bf3ce41
9 changed files with 314 additions and 158 deletions

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ async fn handle_new_listing_command(
dialogue
.update(NewListingState::AwaitingDraftField {
field: ListingField::Title,
draft: ListingDraft::draft_for_seller(user.persisted.id),
draft: ListingDraft::new_for_seller(user.persisted.id),
})
.await?;
@@ -75,7 +75,7 @@ async fn handle_new_listing_command(
}
async fn handle_awaiting_draft_field_input(
bot: &Bot,
bot: Bot,
dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft),
msg: Message,
@@ -90,18 +90,18 @@ async fn handle_awaiting_draft_field_input(
);
if is_cancel(text) {
return cancel_wizard(bot, dialogue, chat).await;
return cancel_wizard(&bot, dialogue, chat).await;
}
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 => {
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::Slots => handle_slots_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::Price => handle_price_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::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(
bot: &Bot,
bot: Bot,
dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery,
@@ -206,7 +206,7 @@ async fn handle_awaiting_draft_field_callback(
let target = (from, message_id);
if data == "cancel" {
return cancel_wizard(bot, dialogue, target).await;
return cancel_wizard(&bot, dialogue, target).await;
}
match field {
@@ -216,7 +216,7 @@ async fn handle_awaiting_draft_field_callback(
Ok(())
}
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 => {
error!("Unknown callback data: {data}");
@@ -224,13 +224,13 @@ async fn handle_awaiting_draft_field_callback(
Ok(())
}
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 => {
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 => {
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,
) -> HandlerResult {
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())
.map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?;
match button {
ConfirmationKeyboardButtons::Create => {
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?;
save_listing(db_pool, bot, dialogue, target, draft).await?;
}
ConfirmationKeyboardButtons::Discard => {
info!("User {target:?} discarded listing creation");
// Exit dialogue and send cancellation message
dialogue.exit().await?;
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");
// 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?;
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
}
}
@@ -583,7 +588,12 @@ async fn display_listing_summary(
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(format!("<b>Title:</b> {}", draft.base.title));
response_lines.push(format!(
@@ -608,8 +618,14 @@ async fn display_listing_summary(
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
}
ListingDraftPersisted::Persisted(fields) => {
response_lines.push(format!("<b>Starts on:</b> {}", fields.start_at));
response_lines.push(format!("<b>Ends on:</b> {}", fields.end_at));
response_lines.push(format!(
"<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(())
}
async fn show_edit_screen(
pub async fn enter_edit_listing_draft(
bot: &Bot,
target: impl Into<MessageTarget>,
draft: &ListingDraft,
draft: ListingDraft,
dialogue: RootDialogue,
flash_message: Option<&str>,
) -> HandlerResult {
display_listing_summary(
bot,
target,
draft,
&draft,
Some(FieldSelectionKeyboardButtons::to_keyboard()),
flash_message,
)
.await?;
dialogue
.update(NewListingState::EditingDraft(draft))
.await?;
Ok(())
}
@@ -643,19 +664,25 @@ async fn show_confirmation_screen(
target: impl Into<MessageTarget>,
draft: &ListingDraft,
) -> HandlerResult {
display_listing_summary(
bot,
target,
draft,
Some(ConfirmationKeyboardButtons::to_keyboard()),
None,
)
.await?;
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(())
}
async fn handle_editing_field_input(
bot: &Bot,
bot: Bot,
dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft),
msg: Message,
@@ -667,22 +694,22 @@ async fn handle_editing_field_input(
match field {
ListingField::Title => {
handle_edit_title(bot, dialogue, draft, text, chat).await?;
handle_edit_title(&bot, dialogue, draft, text, chat).await?;
}
ListingField::Description => {
handle_edit_description(bot, dialogue, draft, text, chat).await?;
handle_edit_description(&bot, dialogue, draft, text, chat).await?;
}
ListingField::Price => {
handle_edit_price(bot, dialogue, draft, text, chat).await?;
handle_edit_price(&bot, dialogue, draft, text, chat).await?;
}
ListingField::Slots => {
handle_edit_slots(bot, dialogue, draft, text, chat).await?;
handle_edit_slots(&bot, dialogue, draft, text, chat).await?;
}
ListingField::StartTime => {
handle_edit_start_time(bot, dialogue, draft, text, chat).await?;
handle_edit_start_time(&bot, dialogue, draft, text, chat).await?;
}
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(
bot: &Bot,
bot: Bot,
draft: ListingDraft,
dialogue: RootDialogue,
callback_query: CallbackQuery,
@@ -759,7 +786,7 @@ async fn handle_editing_draft_callback(
create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()),
),
FieldSelectionKeyboardButtons::Done => {
show_confirmation_screen(bot, target, &draft).await?;
show_confirmation_screen(&bot, target, &draft).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::ViewingDraft(draft),
@@ -790,13 +817,12 @@ async fn handle_editing_draft_callback(
async fn save_listing(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
target: impl Into<MessageTarget>,
draft: ListingDraft,
) -> HandlerResult {
let listing: PersistedListing = match draft.persisted {
let (listing, success_message) = match draft.persisted {
ListingDraftPersisted::New(fields) => {
ListingDAO::insert_listing(
let listing = ListingDAO::insert_listing(
&db_pool,
NewListing {
persisted: fields,
@@ -804,10 +830,11 @@ async fn save_listing(
fields: draft.fields,
},
)
.await?
.await?;
(listing, "Listing created!")
}
ListingDraftPersisted::Persisted(fields) => {
ListingDAO::update_listing(
let listing = ListingDAO::update_listing(
&db_pool,
PersistedListing {
persisted: fields,
@@ -815,21 +842,13 @@ async fn save_listing(
fields: draft.fields,
},
)
.await?
.await?;
(listing, "Listing updated!")
}
};
let response = format!(
"✅ <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?;
let response = format!("✅ <b>{}</b>: {}", success_message, listing.base.title);
send_message(&bot, target, response, None).await?;
Ok(())
}
@@ -857,14 +876,8 @@ async fn handle_edit_title(
return Ok(());
}
};
// Go back to editing listing state
show_edit_screen(bot, target, &draft, Some("✅ Title updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
draft.has_changes = true;
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Title updated!")).await?;
Ok(())
}
@@ -885,14 +898,16 @@ async fn handle_edit_description(
return Ok(());
}
};
draft.has_changes = true;
// Go back to editing listing state
show_edit_screen(bot, target, &draft, Some("✅ Description updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
enter_edit_listing_draft(
bot,
target,
draft,
dialogue,
Some("✅ Description updated!"),
)
.await?;
Ok(())
}
@@ -919,15 +934,9 @@ async fn handle_edit_price(
}
};
// Go back to editing listing state
show_edit_screen(bot, target, &draft, Some("✅ Price updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
draft.has_changes = true;
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Price updated!")).await?;
Ok(())
}
@@ -956,13 +965,9 @@ async fn handle_edit_slots(
}
};
show_edit_screen(bot, target, &draft, Some("✅ Slots updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
draft.has_changes = true;
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Slots updated!")).await?;
Ok(())
}
@@ -989,13 +994,9 @@ async fn handle_edit_start_time(
}
};
// Go back to editing listing state
show_edit_screen(bot, target, &draft, Some("✅ Start time updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
draft.has_changes = true;
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Start time updated!")).await?;
Ok(())
}
@@ -1021,18 +1022,14 @@ async fn handle_edit_duration(
return Ok(());
}
};
draft.has_changes = true;
show_edit_screen(bot, target, &draft, Some("✅ Duration updated!")).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
enter_edit_listing_draft(bot, target, draft, dialogue, Some("✅ Duration updated!")).await?;
Ok(())
}
async fn handle_editing_draft_field_callback(
bot: &Bot,
bot: Bot,
dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery,
@@ -1041,33 +1038,28 @@ async fn handle_editing_draft_field_callback(
let target = (from, message_id);
info!("User {:?} editing field: {:?} -> {}", target, field, &data);
if data == "edit_back" {
show_edit_screen(bot, target, &draft, None).await?;
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraft(draft),
))
.await?;
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
return Ok(());
}
match field {
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 => {
handle_edit_description(bot, dialogue, draft, data.as_str(), target).await?;
handle_edit_description(&bot, dialogue, draft, data.as_str(), target).await?;
}
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 => {
handle_edit_slots(bot, dialogue, draft, data.as_str(), target).await?;
handle_edit_slots(&bot, dialogue, draft, data.as_str(), target).await?;
}
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 => {
handle_edit_duration(bot, dialogue, draft, data.as_str(), target).await?;
handle_edit_duration(&bot, dialogue, draft, data.as_str(), target).await?;
}
};

View File

@@ -1,6 +1,8 @@
use crate::{
db::{
listing::{ListingBase, ListingFields, NewListingFields, PersistedListingFields},
listing::{
ListingBase, ListingFields, NewListingFields, PersistedListing, PersistedListingFields,
},
MoneyAmount, UserDbId,
},
DialogueRootState,
@@ -9,14 +11,16 @@ 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,
@@ -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)]

View File

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

View File

@@ -89,7 +89,6 @@ pub enum ListingFields {
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ListingDbId;
use crate::db::{ListingDAO, TelegramUserDbId};
use rstest::rstest;
use sqlx::SqlitePool;

View File

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

View File

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

View File

@@ -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()
}