major db refactors
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -121,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
|
checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"itertools",
|
"itertools 0.10.5",
|
||||||
"proc-macro-error2",
|
"proc-macro-error2",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1213,6 +1213,15 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -1593,6 +1602,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
|
"itertools 0.14.0",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"log",
|
"log",
|
||||||
"num",
|
"num",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { version = "1.0.219" }
|
||||||
teloxide = { version = "0.17.0", features = ["macros", "ctrlc_handler"] }
|
teloxide = { version = "0.17.0", features = ["macros", "ctrlc_handler"] }
|
||||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
|
||||||
sqlx = { version = "0.8.6", features = [
|
sqlx = { version = "0.8.6", features = [
|
||||||
@@ -14,17 +15,17 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
] }
|
] }
|
||||||
rust_decimal = { version = "1.33" }
|
rust_decimal = { version = "1.33" }
|
||||||
chrono = { version = "0.4" }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
serde = "1.0.219"
|
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
thiserror = "2.0.16"
|
thiserror = "2.0.16"
|
||||||
teloxide-core = "0.13.0"
|
teloxide-core = "0.13.0"
|
||||||
num = "0.4.3"
|
num = "0.4.3"
|
||||||
|
itertools = "0.14.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.26.1"
|
rstest = "0.26.1"
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ CREATE TABLE users (
|
|||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
telegram_id INTEGER UNIQUE NOT NULL,
|
telegram_id INTEGER UNIQUE NOT NULL,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
display_name TEXT,
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
is_banned INTEGER DEFAULT 0,
|
is_banned INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
case,
|
case,
|
||||||
db::{Listing, ListingDAO, ListingId, User, 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},
|
||||||
Command, DialogueRootState, HandlerResult, RootDialogue,
|
Command, DialogueRootState, HandlerResult, RootDialogue,
|
||||||
@@ -17,8 +17,8 @@ use teloxide::{
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum MyListingsState {
|
pub enum MyListingsState {
|
||||||
ViewingListings,
|
ViewingListings,
|
||||||
ManagingListing(ListingId),
|
ManagingListing(ListingDbId),
|
||||||
EditingListing(ListingId),
|
EditingListing(ListingDbId),
|
||||||
}
|
}
|
||||||
impl From<MyListingsState> for DialogueRootState {
|
impl From<MyListingsState> for DialogueRootState {
|
||||||
fn from(state: MyListingsState) -> Self {
|
fn from(state: MyListingsState) -> Self {
|
||||||
@@ -99,7 +99,7 @@ async fn show_listings_for_user(
|
|||||||
// Transition to ViewingListings state
|
// Transition to ViewingListings state
|
||||||
dialogue.update(MyListingsState::ViewingListings).await?;
|
dialogue.update(MyListingsState::ViewingListings).await?;
|
||||||
|
|
||||||
let listings = ListingDAO::find_by_seller(&db_pool, user.id).await?;
|
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
|
||||||
if listings.is_empty() {
|
if listings.is_empty() {
|
||||||
send_message(
|
send_message(
|
||||||
&bot,
|
&bot,
|
||||||
@@ -118,7 +118,7 @@ async fn show_listings_for_user(
|
|||||||
for listing in &listings {
|
for listing in &listings {
|
||||||
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
|
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
|
||||||
listing.base.title.to_string(),
|
listing.base.title.to_string(),
|
||||||
listing.base.id.to_string(),
|
listing.persisted.id.to_string(),
|
||||||
)]);
|
)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ async fn show_listings_for_user(
|
|||||||
for listing in &listings {
|
for listing in &listings {
|
||||||
response.push_str(&format!(
|
response.push_str(&format!(
|
||||||
"• <b>ID {}:</b> {}\n",
|
"• <b>ID {}:</b> {}\n",
|
||||||
listing.base.id, listing.base.title
|
listing.persisted.id, listing.base.title
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ async fn handle_viewing_listings_callback(
|
|||||||
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.clone(), message_id);
|
let target = (from.clone(), message_id);
|
||||||
|
|
||||||
let listing_id = ListingId::new(data.parse::<i64>()?);
|
let listing_id = ListingDbId::new(data.parse::<i64>()?);
|
||||||
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
|
dialogue
|
||||||
@@ -163,7 +163,7 @@ async fn handle_viewing_listings_callback(
|
|||||||
|
|
||||||
async fn show_listing_details(
|
async fn show_listing_details(
|
||||||
bot: &Bot,
|
bot: &Bot,
|
||||||
listing: Listing,
|
listing: PersistedListing,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let response = format!(
|
let response = format!(
|
||||||
@@ -177,7 +177,7 @@ async fn show_listing_details(
|
|||||||
.description
|
.description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("No description"),
|
.unwrap_or("No description"),
|
||||||
listing.base.id
|
listing.persisted.id
|
||||||
);
|
);
|
||||||
|
|
||||||
send_message(
|
send_message(
|
||||||
@@ -195,7 +195,7 @@ async fn handle_managing_listing_callback(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
callback_query: CallbackQuery,
|
callback_query: CallbackQuery,
|
||||||
listing_id: ListingId,
|
listing_id: ListingDbId,
|
||||||
) -> 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.clone(), message_id);
|
let target = (from.clone(), message_id);
|
||||||
@@ -208,7 +208,7 @@ async fn handle_managing_listing_callback(
|
|||||||
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
|
dialogue
|
||||||
.update(MyListingsState::EditingListing(listing.base.id))
|
.update(MyListingsState::EditingListing(listing.persisted.id))
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
ManageListingButtons::Delete => {
|
ManageListingButtons::Delete => {
|
||||||
@@ -228,9 +228,9 @@ async fn get_user_and_listing(
|
|||||||
db_pool: &SqlitePool,
|
db_pool: &SqlitePool,
|
||||||
bot: &Bot,
|
bot: &Bot,
|
||||||
user_id: teloxide::types::UserId,
|
user_id: teloxide::types::UserId,
|
||||||
listing_id: ListingId,
|
listing_id: ListingDbId,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult<(User, Listing)> {
|
) -> HandlerResult<(PersistedUser, PersistedListing)> {
|
||||||
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
|
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => {
|
None => {
|
||||||
@@ -253,7 +253,7 @@ async fn get_user_and_listing(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if listing.base.seller_id != user.id {
|
if listing.base.seller_id != user.persisted.id {
|
||||||
send_message(
|
send_message(
|
||||||
bot,
|
bot,
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ mod validations;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
dao::ListingDAO,
|
listing::{ListingFields, NewListing, PersistedListing},
|
||||||
models::new_listing::{NewListing, NewListingBase, NewListingFields},
|
ListingDAO, ListingDuration, UserDAO,
|
||||||
ListingDuration, NewUser, UserDAO,
|
|
||||||
},
|
},
|
||||||
message_utils::*,
|
message_utils::*,
|
||||||
DialogueRootState, HandlerResult, RootDialogue,
|
DialogueRootState, HandlerResult, RootDialogue,
|
||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
pub use handler_factory::new_listing_handler;
|
pub use handler_factory::new_listing_handler;
|
||||||
use keyboard::*;
|
use keyboard::*;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
@@ -33,10 +31,7 @@ fn create_back_button_keyboard() -> InlineKeyboardMarkup {
|
|||||||
fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup {
|
fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup {
|
||||||
create_single_row_keyboard(&[
|
create_single_row_keyboard(&[
|
||||||
("🔙 Back", "edit_back"),
|
("🔙 Back", "edit_back"),
|
||||||
(
|
(&format!("🧹 Clear {field}"), &format!("edit_clear_{field}")),
|
||||||
&format!("🧹 Clear {field}"),
|
|
||||||
&format!("edit_clear_{field}"),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +45,7 @@ fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup {
|
|||||||
|
|
||||||
// Handle the /newlisting command - starts the dialogue by setting it to Start state
|
// Handle the /newlisting command - starts the dialogue by setting it to Start state
|
||||||
async fn handle_new_listing_command(
|
async fn handle_new_listing_command(
|
||||||
|
db_pool: SqlitePool,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -58,12 +54,14 @@ async fn handle_new_listing_command(
|
|||||||
"User {} started new fixed price listing wizard",
|
"User {} started new fixed price listing wizard",
|
||||||
HandleAndId::from_chat(&msg.chat),
|
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
|
// Initialize the dialogue to Start state
|
||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::AwaitingDraftField {
|
.update(NewListingState::AwaitingDraftField {
|
||||||
field: ListingField::Title,
|
field: ListingField::Title,
|
||||||
draft: ListingDraft::default(),
|
draft: ListingDraft::draft_for_seller(user.persisted.id),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -77,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,
|
||||||
@@ -108,7 +106,7 @@ async fn handle_awaiting_draft_field_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_title_input(
|
async fn handle_title_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
@@ -116,7 +114,7 @@ async fn handle_title_input(
|
|||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
match validate_title(text) {
|
match validate_title(text) {
|
||||||
Ok(title) => {
|
Ok(title) => {
|
||||||
draft.title = title;
|
draft.base.title = title;
|
||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::AwaitingDraftField {
|
.update(NewListingState::AwaitingDraftField {
|
||||||
field: ListingField::Description,
|
field: ListingField::Description,
|
||||||
@@ -135,13 +133,13 @@ async fn handle_title_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_description_input(
|
async fn handle_description_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut draft: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
draft.description = match validate_description(text) {
|
draft.base.description = match validate_description(text) {
|
||||||
Ok(description) => Some(description),
|
Ok(description) => Some(description),
|
||||||
Err(error_msg) => {
|
Err(error_msg) => {
|
||||||
send_message(&bot, chat, error_msg, None).await?;
|
send_message(&bot, chat, error_msg, None).await?;
|
||||||
@@ -165,7 +163,7 @@ async fn handle_description_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_description_callback(
|
async fn handle_description_callback(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
data: &str,
|
data: &str,
|
||||||
@@ -198,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,
|
||||||
@@ -238,7 +236,7 @@ async fn handle_awaiting_draft_field_callback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_slots_callback(
|
async fn handle_slots_callback(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
data: &str,
|
data: &str,
|
||||||
@@ -257,7 +255,7 @@ async fn handle_slots_callback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_start_time_callback(
|
async fn handle_start_time_callback(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
data: &str,
|
data: &str,
|
||||||
@@ -282,8 +280,21 @@ async fn process_slots_and_respond(
|
|||||||
slots: i32,
|
slots: i32,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
|
match &mut draft.fields {
|
||||||
|
ListingFields::FixedPriceListing {
|
||||||
|
slots_available, ..
|
||||||
|
} => {
|
||||||
|
*slots_available = slots;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Unsupported listing type to update slots: {:?}",
|
||||||
|
draft.fields
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Update dialogue state
|
// Update dialogue state
|
||||||
draft.slots_available = slots;
|
|
||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::AwaitingDraftField {
|
.update(NewListingState::AwaitingDraftField {
|
||||||
field: ListingField::StartTime,
|
field: ListingField::StartTime,
|
||||||
@@ -320,7 +331,7 @@ 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.clone(), message_id);
|
let target = (from, 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))?;
|
||||||
@@ -329,10 +340,10 @@ async fn handle_viewing_draft_callback(
|
|||||||
ConfirmationKeyboardButtons::Create => {
|
ConfirmationKeyboardButtons::Create => {
|
||||||
info!("User {target:?} confirmed listing creation");
|
info!("User {target:?} confirmed listing creation");
|
||||||
dialogue.exit().await?;
|
dialogue.exit().await?;
|
||||||
create_listing(db_pool, bot, dialogue, from, message_id, draft.clone()).await?;
|
save_listing(db_pool, bot, dialogue, target, draft).await?;
|
||||||
}
|
}
|
||||||
ConfirmationKeyboardButtons::Discard => {
|
ConfirmationKeyboardButtons::Discard => {
|
||||||
info!("User {from:?} discarded listing creation");
|
info!("User {target:?} discarded listing creation");
|
||||||
|
|
||||||
// Exit dialogue and send cancellation message
|
// Exit dialogue and send cancellation message
|
||||||
dialogue.exit().await?;
|
dialogue.exit().await?;
|
||||||
@@ -344,15 +355,15 @@ async fn handle_viewing_draft_callback(
|
|||||||
send_message(&bot, target, &response, None).await?;
|
send_message(&bot, target, &response, None).await?;
|
||||||
}
|
}
|
||||||
ConfirmationKeyboardButtons::Edit => {
|
ConfirmationKeyboardButtons::Edit => {
|
||||||
info!("User {from:?} chose to edit listing");
|
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
|
// Go to editing state to allow user to modify specific fields
|
||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::EditingDraft(draft.clone()))
|
.update(NewListingState::EditingDraft(draft))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Delete the old message and show the edit screen
|
|
||||||
show_edit_screen(bot, target, draft, None).await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,8 +379,18 @@ async fn process_start_time_and_respond(
|
|||||||
duration: ListingDuration,
|
duration: ListingDuration,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
|
|
||||||
// Update dialogue state
|
// Update dialogue state
|
||||||
draft.start_delay = duration;
|
|
||||||
|
match &mut draft.persisted {
|
||||||
|
ListingDraftPersisted::New(fields) => {
|
||||||
|
fields.start_delay = duration;
|
||||||
|
}
|
||||||
|
ListingDraftPersisted::Persisted(_) => {
|
||||||
|
anyhow::bail!("Cannot update start time for persisted listing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dialogue
|
dialogue
|
||||||
.update(NewListingState::AwaitingDraftField {
|
.update(NewListingState::AwaitingDraftField {
|
||||||
field: ListingField::Duration,
|
field: ListingField::Duration,
|
||||||
@@ -399,7 +420,7 @@ async fn process_start_time_and_respond(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_price_input(
|
async fn handle_price_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
@@ -407,14 +428,21 @@ async fn handle_price_input(
|
|||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
match validate_price(text) {
|
match validate_price(text) {
|
||||||
Ok(price) => {
|
Ok(price) => {
|
||||||
draft.buy_now_price = price;
|
match &mut draft.fields {
|
||||||
|
ListingFields::FixedPriceListing { buy_now_price, .. } => {
|
||||||
|
*buy_now_price = price;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!("Cannot update price for non-fixed price listing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"✅ Price saved: <b>${}</b>\n\n\
|
"✅ Price saved: <b>${}</b>\n\n\
|
||||||
<i>Step 4 of 6: Available Slots</i>\n\
|
<i>Step 4 of 6: Available Slots</i>\n\
|
||||||
How many items are available for sale?\n\n\
|
How many items are available for sale?\n\n\
|
||||||
Choose a common value below or enter a custom number (1-1000):",
|
Choose a common value below or enter a custom number (1-1000):",
|
||||||
draft.buy_now_price
|
price
|
||||||
);
|
);
|
||||||
|
|
||||||
dialogue
|
dialogue
|
||||||
@@ -439,7 +467,7 @@ async fn handle_price_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_slots_input(
|
async fn handle_slots_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
@@ -458,7 +486,7 @@ async fn handle_slots_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_start_time_input(
|
async fn handle_start_time_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
@@ -483,7 +511,7 @@ async fn handle_start_time_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_duration_input(
|
async fn handle_duration_input(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
text: &str,
|
text: &str,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
@@ -501,7 +529,7 @@ async fn handle_duration_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_duration_callback(
|
async fn handle_duration_callback(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
data: &str,
|
data: &str,
|
||||||
@@ -518,108 +546,116 @@ async fn handle_duration_callback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_duration_and_respond(
|
async fn process_duration_and_respond(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut draft: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
duration: ListingDuration,
|
duration: ListingDuration,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
draft.duration = duration;
|
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
|
dialogue
|
||||||
.update(NewListingState::ViewingDraft(draft.clone()))
|
.update(NewListingState::ViewingDraft(draft))
|
||||||
.await?;
|
.await?;
|
||||||
show_confirmation(bot, target, draft).await
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_confirmation(
|
async fn display_listing_summary(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
state: ListingDraft,
|
draft: &ListingDraft,
|
||||||
|
keyboard: Option<InlineKeyboardMarkup>,
|
||||||
|
flash_message: Option<&str>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let description_text = state
|
let mut response_lines = vec![];
|
||||||
.description
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("<i>No description</i>");
|
|
||||||
|
|
||||||
let start_time_str = format!("In {}", state.start_delay);
|
if let Some(flash_message) = flash_message {
|
||||||
|
response_lines.push(flash_message.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let response = format!(
|
response_lines.push("📋 <i><b>Listing Summary</b></i>".to_string());
|
||||||
"📋 <b>Listing Summary</b>\n\n\
|
response_lines.push("".to_string());
|
||||||
<b>Title:</b> {}\n\
|
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
|
||||||
<b>Description:</b> {}\n\
|
response_lines.push(format!(
|
||||||
<b>Price:</b> ${}\n\
|
"📄 <b>Description:</b> {}",
|
||||||
<b>Available Slots:</b> {}\n\
|
draft
|
||||||
<b>Start Time:</b> {}\n\
|
.base
|
||||||
<b>Duration:</b> {}\n\n\
|
.description
|
||||||
Please review your listing and choose an action:",
|
.as_deref()
|
||||||
state.title,
|
.unwrap_or("<i>No description</i>")
|
||||||
description_text,
|
));
|
||||||
state.buy_now_price,
|
|
||||||
state.slots_available,
|
|
||||||
start_time_str,
|
|
||||||
state.duration
|
|
||||||
);
|
|
||||||
|
|
||||||
send_message(
|
match &draft.fields {
|
||||||
&bot,
|
ListingFields::FixedPriceListing { buy_now_price, .. } => {
|
||||||
target,
|
response_lines.push(format!("💰 <b>Buy it Now Price:</b> ${}", buy_now_price));
|
||||||
&response,
|
}
|
||||||
Some(ConfirmationKeyboardButtons::to_keyboard()),
|
_ => {}
|
||||||
)
|
}
|
||||||
.await?;
|
|
||||||
|
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> {}", fields.start_at));
|
||||||
|
response_lines.push(format!("<b>Ends on:</b> {}", 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_edit_screen(
|
async fn show_edit_screen(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
state: ListingDraft,
|
draft: &ListingDraft,
|
||||||
flash_message: Option<&str>,
|
flash_message: Option<&str>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
display_listing_summary(
|
||||||
let description_text = state
|
bot,
|
||||||
.description
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("<i>No description</i>");
|
|
||||||
|
|
||||||
let start_time_str = format!("In {}", state.start_delay);
|
|
||||||
|
|
||||||
let mut response = format!(
|
|
||||||
"✏️ <b>Editing Listing:</b>\n\n\
|
|
||||||
📝 <b>Title:</b> {}\n\
|
|
||||||
📄 <b>Description:</b> {}\n\
|
|
||||||
💰 <b>Price:</b> ${}\n\
|
|
||||||
🔢 <b>Available Slots:</b> {}\n\
|
|
||||||
⏰ <b>Start Time:</b> {}\n\
|
|
||||||
⏳ <b>Duration:</b> {}\n\n\
|
|
||||||
Select a field to edit:",
|
|
||||||
state.title,
|
|
||||||
description_text,
|
|
||||||
state.buy_now_price,
|
|
||||||
state.slots_available,
|
|
||||||
start_time_str,
|
|
||||||
state.duration
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(flash_message) = flash_message {
|
|
||||||
response = format!("{flash_message}\n\n{response}");
|
|
||||||
}
|
|
||||||
|
|
||||||
send_message(
|
|
||||||
&bot,
|
|
||||||
target,
|
target,
|
||||||
&response,
|
draft,
|
||||||
Some(FieldSelectionKeyboardButtons::to_keyboard()),
|
Some(FieldSelectionKeyboardButtons::to_keyboard()),
|
||||||
|
flash_message,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show_confirmation_screen(
|
||||||
|
bot: &Bot,
|
||||||
|
target: impl Into<MessageTarget>,
|
||||||
|
draft: &ListingDraft,
|
||||||
|
) -> HandlerResult {
|
||||||
|
display_listing_summary(
|
||||||
|
bot,
|
||||||
|
target,
|
||||||
|
draft,
|
||||||
|
Some(ConfirmationKeyboardButtons::to_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,
|
||||||
@@ -654,30 +690,28 @@ 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,
|
||||||
) -> 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.clone(), message_id);
|
let target = (from, message_id);
|
||||||
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())
|
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?;
|
||||||
|
|
||||||
info!(
|
info!("User {target:?} in editing screen, showing field selection");
|
||||||
"User {} in editing screen, showing field selection",
|
|
||||||
HandleAndId::from_user(&from)
|
|
||||||
);
|
|
||||||
|
|
||||||
let (field, value, keyboard) = match button {
|
let (field, value, keyboard) = match button {
|
||||||
FieldSelectionKeyboardButtons::Title => (
|
FieldSelectionKeyboardButtons::Title => (
|
||||||
ListingField::Title,
|
ListingField::Title,
|
||||||
draft.title.clone(),
|
draft.base.title.clone(),
|
||||||
create_back_button_keyboard(),
|
create_back_button_keyboard(),
|
||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::Description => (
|
FieldSelectionKeyboardButtons::Description => (
|
||||||
ListingField::Description,
|
ListingField::Description,
|
||||||
draft
|
draft
|
||||||
|
.base
|
||||||
.description
|
.description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("(no description)")
|
.unwrap_or("(no description)")
|
||||||
@@ -686,41 +720,58 @@ async fn handle_editing_draft_callback(
|
|||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::Price => (
|
FieldSelectionKeyboardButtons::Price => (
|
||||||
ListingField::Price,
|
ListingField::Price,
|
||||||
format!("${}", draft.buy_now_price),
|
match &draft.fields {
|
||||||
|
ListingFields::FixedPriceListing { buy_now_price, .. } => {
|
||||||
|
format!("${}", buy_now_price)
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("Cannot update price for non-fixed price listing"),
|
||||||
|
},
|
||||||
create_back_button_keyboard(),
|
create_back_button_keyboard(),
|
||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::Slots => (
|
FieldSelectionKeyboardButtons::Slots => (
|
||||||
ListingField::Slots,
|
ListingField::Slots,
|
||||||
format!("{} slots", draft.slots_available),
|
match &draft.fields {
|
||||||
|
ListingFields::FixedPriceListing {
|
||||||
|
slots_available, ..
|
||||||
|
} => {
|
||||||
|
format!("{} slots", slots_available)
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"),
|
||||||
|
},
|
||||||
create_back_button_keyboard_with(SlotsKeyboardButtons::to_keyboard()),
|
create_back_button_keyboard_with(SlotsKeyboardButtons::to_keyboard()),
|
||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::StartTime => (
|
FieldSelectionKeyboardButtons::StartTime => (
|
||||||
ListingField::StartTime,
|
ListingField::StartTime,
|
||||||
draft.start_delay.to_string(),
|
match &draft.persisted {
|
||||||
|
ListingDraftPersisted::New(fields) => {
|
||||||
|
format!("{} hours", fields.start_delay)
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("Cannot update start time of an existing listing"),
|
||||||
|
},
|
||||||
create_back_button_keyboard_with(StartTimeKeyboardButtons::to_keyboard()),
|
create_back_button_keyboard_with(StartTimeKeyboardButtons::to_keyboard()),
|
||||||
),
|
),
|
||||||
FieldSelectionKeyboardButtons::Duration => (
|
FieldSelectionKeyboardButtons::Duration => (
|
||||||
ListingField::Duration,
|
ListingField::Duration,
|
||||||
draft.duration.to_string(),
|
match &draft.persisted {
|
||||||
|
ListingDraftPersisted::New(fields) => fields.end_delay.to_string(),
|
||||||
|
_ => anyhow::bail!("Cannot update duration of an existing listing"),
|
||||||
|
},
|
||||||
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?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::ViewingDraft(draft.clone()),
|
NewListingState::ViewingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
show_confirmation(bot, target, draft).await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraftField {
|
NewListingState::EditingDraftField { field, draft },
|
||||||
field,
|
|
||||||
draft: draft.clone(),
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -736,99 +787,55 @@ async fn handle_editing_draft_callback(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_listing(
|
async fn save_listing(
|
||||||
db_pool: SqlitePool,
|
db_pool: SqlitePool,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
from: User,
|
target: impl Into<MessageTarget>,
|
||||||
message_id: MessageId,
|
|
||||||
draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let now = Utc::now();
|
let listing: PersistedListing = match draft.persisted {
|
||||||
let starts_at = now + Into::<Duration>::into(draft.start_delay);
|
ListingDraftPersisted::New(fields) => {
|
||||||
let ends_at = starts_at + Into::<Duration>::into(draft.duration);
|
ListingDAO::insert_listing(
|
||||||
|
|
||||||
let user = match UserDAO::find_by_telegram_id(&db_pool, from.id).await? {
|
|
||||||
Some(user) => user,
|
|
||||||
None => {
|
|
||||||
UserDAO::insert_user(
|
|
||||||
&db_pool,
|
&db_pool,
|
||||||
&NewUser {
|
NewListing {
|
||||||
telegram_id: from.id.into(),
|
persisted: fields,
|
||||||
username: from.username.clone(),
|
base: draft.base,
|
||||||
display_name: Some(from.first_name.clone()),
|
fields: draft.fields,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
ListingDraftPersisted::Persisted(fields) => {
|
||||||
|
ListingDAO::update_listing(
|
||||||
|
&db_pool,
|
||||||
|
PersistedListing {
|
||||||
|
persisted: fields,
|
||||||
|
base: draft.base,
|
||||||
|
fields: draft.fields,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_listing_base = NewListingBase::new(
|
let response = format!(
|
||||||
user.id,
|
"✅ <b>Listing Created Successfully!</b>\n\n\
|
||||||
draft.title.clone(),
|
|
||||||
draft.description.clone(),
|
|
||||||
starts_at,
|
|
||||||
ends_at,
|
|
||||||
);
|
|
||||||
|
|
||||||
let new_listing = NewListing {
|
|
||||||
base: new_listing_base,
|
|
||||||
fields: NewListingFields::FixedPriceListing {
|
|
||||||
buy_now_price: draft.buy_now_price,
|
|
||||||
slots_available: draft.slots_available,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
match ListingDAO::insert_listing(&db_pool, &new_listing).await {
|
|
||||||
Ok(listing) => {
|
|
||||||
let response = format!(
|
|
||||||
"✅ <b>Listing Created Successfully!</b>\n\n\
|
|
||||||
<b>Listing ID:</b> {}\n\
|
<b>Listing ID:</b> {}\n\
|
||||||
<b>Title:</b> {}\n\
|
<b>Title:</b> {}\n\
|
||||||
<b>Price:</b> ${}\n\
|
|
||||||
<b>Slots Available:</b> {}\n\n\
|
|
||||||
Your fixed price listing is now live! 🎉",
|
Your fixed price listing is now live! 🎉",
|
||||||
listing.base.id, listing.base.title, draft.buy_now_price, draft.slots_available
|
listing.persisted.id, listing.base.title
|
||||||
);
|
);
|
||||||
|
|
||||||
send_message(&bot, (from.clone(), message_id), response, None).await?;
|
|
||||||
dialogue.exit().await?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Fixed price listing created successfully for user {:?}: {:?}",
|
|
||||||
from.id, listing.base.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to create listing for user {from:?}: {e}");
|
|
||||||
send_message(
|
|
||||||
&bot,
|
|
||||||
(from, message_id),
|
|
||||||
"❌ <b>Error:</b> Failed to create listing. Please try again later.",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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?;
|
dialogue.exit().await?;
|
||||||
send_message(&bot, target, "❌ Listing creation cancelled.", None).await?;
|
send_message(&bot, target, response, None).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Individual field editing handlers
|
// Individual field editing handlers
|
||||||
async fn handle_edit_title(
|
async fn handle_edit_title(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut draft: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -837,7 +844,7 @@ async fn handle_edit_title(
|
|||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing title: '{text}'");
|
info!("User {target:?} editing title: '{text}'");
|
||||||
|
|
||||||
draft.title = match validate_title(text) {
|
draft.base.title = match validate_title(text) {
|
||||||
Ok(title) => title,
|
Ok(title) => title,
|
||||||
Err(error_msg) => {
|
Err(error_msg) => {
|
||||||
send_message(
|
send_message(
|
||||||
@@ -852,27 +859,26 @@ async fn handle_edit_title(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
// Go back to editing listing state
|
||||||
|
show_edit_screen(bot, target, &draft, Some("✅ Title updated!")).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(draft.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, draft, Some("✅ Title updated!")).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_edit_description(
|
async fn handle_edit_description(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut state: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing description: '{text}'");
|
info!("User {target:?} editing description: '{text}'");
|
||||||
|
|
||||||
state.description = match validate_description(text) {
|
draft.base.description = match validate_description(text) {
|
||||||
Ok(description) => Some(description),
|
Ok(description) => Some(description),
|
||||||
Err(error_msg) => {
|
Err(error_msg) => {
|
||||||
send_message(&bot, target, error_msg, None).await?;
|
send_message(&bot, target, error_msg, None).await?;
|
||||||
@@ -881,28 +887,31 @@ async fn handle_edit_description(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
// Go back to editing listing state
|
||||||
|
show_edit_screen(bot, target, &draft, Some("✅ Description updated!")).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(state.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, state, Some("✅ Description updated!")).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_edit_price(
|
async fn handle_edit_price(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut state: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing price: '{text}'");
|
info!("User {target:?} editing price: '{text}'");
|
||||||
|
|
||||||
state.buy_now_price = match validate_price(text) {
|
let buy_now_price = match &mut draft.fields {
|
||||||
|
ListingFields::FixedPriceListing { buy_now_price, .. } => buy_now_price,
|
||||||
|
_ => anyhow::bail!("Cannot update price for non-fixed price listing"),
|
||||||
|
};
|
||||||
|
|
||||||
|
*buy_now_price = match validate_price(text) {
|
||||||
Ok(price) => price,
|
Ok(price) => price,
|
||||||
Err(error_msg) => {
|
Err(error_msg) => {
|
||||||
send_message(&bot, target, error_msg, None).await?;
|
send_message(&bot, target, error_msg, None).await?;
|
||||||
@@ -911,27 +920,35 @@ async fn handle_edit_price(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
// Go back to editing listing state
|
||||||
|
show_edit_screen(bot, target, &draft, Some("✅ Price updated!")).await?;
|
||||||
|
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(state.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, state, Some("✅ Price updated!")).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_edit_slots(
|
async fn handle_edit_slots(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut state: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing slots: '{text}'");
|
info!("User {target:?} editing slots: '{text}'");
|
||||||
|
|
||||||
state.slots_available = match validate_slots(text) {
|
let slots_available = match &mut draft.fields {
|
||||||
|
ListingFields::FixedPriceListing {
|
||||||
|
slots_available, ..
|
||||||
|
} => slots_available,
|
||||||
|
_ => anyhow::bail!("Cannot update slots for non-fixed price listing"),
|
||||||
|
};
|
||||||
|
|
||||||
|
*slots_available = match validate_slots(text) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(error_msg) => {
|
Err(error_msg) => {
|
||||||
send_message(&bot, target, error_msg, None).await?;
|
send_message(&bot, target, error_msg, None).await?;
|
||||||
@@ -939,106 +956,97 @@ async fn handle_edit_slots(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
show_edit_screen(bot, target, &draft, Some("✅ Slots updated!")).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(state.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, state, Some("✅ Slots updated!")).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_edit_start_time(
|
async fn handle_edit_start_time(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut state: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing start time: '{text}'");
|
info!("User {target:?} editing start time: '{text}'");
|
||||||
|
|
||||||
state.start_delay = match validate_start_time(text) {
|
let fields = match &mut draft.persisted {
|
||||||
|
ListingDraftPersisted::New(fields) => fields,
|
||||||
|
_ => anyhow::bail!("Cannot update start time of an existing listing"),
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.start_delay = match validate_start_time(text) {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
_ => {
|
Err(error_msg) => {
|
||||||
send_message(
|
send_message(&bot, target, error_msg, Some(create_back_button_keyboard())).await?;
|
||||||
&bot,
|
|
||||||
target,
|
|
||||||
"❌ Invalid number. Please enter hours from now (0-168):",
|
|
||||||
Some(create_back_button_keyboard()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
// Go back to editing listing state
|
||||||
|
show_edit_screen(bot, target, &draft, Some("✅ Start time updated!")).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(state.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, state, Some("✅ Start time updated!")).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_edit_duration(
|
async fn handle_edit_duration(
|
||||||
bot: Bot,
|
bot: &Bot,
|
||||||
dialogue: RootDialogue,
|
dialogue: RootDialogue,
|
||||||
mut state: ListingDraft,
|
mut draft: ListingDraft,
|
||||||
text: &str,
|
text: &str,
|
||||||
target: impl Into<MessageTarget>,
|
target: impl Into<MessageTarget>,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let target = target.into();
|
let target = target.into();
|
||||||
info!("User {target:?} editing duration: '{text}'");
|
info!("User {target:?} editing duration: '{text}'");
|
||||||
|
|
||||||
state.duration = match validate_duration(text) {
|
let fields = match &mut draft.persisted {
|
||||||
|
ListingDraftPersisted::New(fields) => fields,
|
||||||
|
_ => anyhow::bail!("Cannot update duration of an existing listing"),
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.end_delay = match validate_duration(text) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
_ => {
|
Err(error_msg) => {
|
||||||
send_message(
|
send_message(&bot, target, error_msg, Some(create_back_button_keyboard())).await?;
|
||||||
&bot,
|
|
||||||
target,
|
|
||||||
"❌ Invalid number. Please enter duration in hours (1-720):",
|
|
||||||
Some(create_back_button_keyboard()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to editing listing state
|
show_edit_screen(bot, target, &draft, Some("✅ Duration updated!")).await?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(state.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_edit_screen(bot, target, state, Some("✅ Duration updated!")).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,
|
||||||
) -> 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.clone(), 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?;
|
||||||
dialogue
|
dialogue
|
||||||
.update(DialogueRootState::NewListing(
|
.update(DialogueRootState::NewListing(
|
||||||
NewListingState::EditingDraft(draft.clone()),
|
NewListingState::EditingDraft(draft),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
show_edit_screen(bot, target, draft, None).await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,3 +1073,15 @@ async fn handle_editing_draft_field_callback(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{ListingDuration, MoneyAmount},
|
db::{
|
||||||
|
listing::{ListingBase, ListingFields, NewListingFields, PersistedListingFields},
|
||||||
|
MoneyAmount, UserDbId,
|
||||||
|
},
|
||||||
DialogueRootState,
|
DialogueRootState,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ListingDraft {
|
pub struct ListingDraft {
|
||||||
pub title: String,
|
pub persisted: ListingDraftPersisted,
|
||||||
pub description: Option<String>,
|
pub base: ListingBase,
|
||||||
pub buy_now_price: MoneyAmount,
|
pub fields: ListingFields,
|
||||||
pub slots_available: i32,
|
}
|
||||||
pub start_delay: ListingDuration,
|
|
||||||
pub duration: ListingDuration,
|
impl ListingDraft {
|
||||||
|
pub fn draft_for_seller(seller_id: UserDbId) -> Self {
|
||||||
|
Self {
|
||||||
|
persisted: ListingDraftPersisted::New(NewListingFields::default()),
|
||||||
|
base: ListingBase {
|
||||||
|
seller_id,
|
||||||
|
title: "".to_string(),
|
||||||
|
description: None,
|
||||||
|
},
|
||||||
|
fields: ListingFields::FixedPriceListing {
|
||||||
|
buy_now_price: MoneyAmount::default(),
|
||||||
|
slots_available: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum ListingDraftPersisted {
|
||||||
|
New(NewListingFields),
|
||||||
|
Persisted(PersistedListingFields),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
|
#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
|
||||||
|
|||||||
64
src/db/bind_fields.rs
Normal file
64
src/db/bind_fields.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::iter::repeat;
|
||||||
|
|
||||||
|
use sqlx::{prelude::*, query::Query, sqlite::SqliteArguments, Encode, Sqlite};
|
||||||
|
|
||||||
|
type BindFn = Box<
|
||||||
|
dyn for<'q> FnOnce(
|
||||||
|
Query<'q, Sqlite, SqliteArguments<'q>>,
|
||||||
|
) -> Query<'q, Sqlite, SqliteArguments<'q>>
|
||||||
|
+ Send,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn make_bind_fn<T>(value: T) -> BindFn
|
||||||
|
where
|
||||||
|
T: for<'q> Encode<'q, Sqlite> + Type<Sqlite> + Send + 'static,
|
||||||
|
{
|
||||||
|
Box::new(move |query| query.bind(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BindFields {
|
||||||
|
binds: Vec<(&'static str, BindFn)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BindFields {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { binds: vec![] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BindFields {
|
||||||
|
#[must_use]
|
||||||
|
pub fn push<'a>(
|
||||||
|
mut self,
|
||||||
|
field: &'static str,
|
||||||
|
value: &'a (impl for<'q> Encode<'q, Sqlite> + Type<Sqlite> + Send + 'static + Clone),
|
||||||
|
) -> Self {
|
||||||
|
self.binds.push((field, make_bind_fn(value.clone())));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn extend(mut self, other: Self) -> Self {
|
||||||
|
self.binds.extend(other.binds);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_to_query<'q>(
|
||||||
|
self,
|
||||||
|
query: Query<'q, Sqlite, SqliteArguments<'q>>,
|
||||||
|
) -> Query<'q, Sqlite, SqliteArguments<'q>> {
|
||||||
|
let mut query = query;
|
||||||
|
for (_, bind_fn) in self.binds {
|
||||||
|
query = bind_fn(query);
|
||||||
|
}
|
||||||
|
query
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||||
|
self.binds.iter().map(|(name, _)| *name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_placeholders(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||||
|
repeat("?").take(self.binds.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,136 +3,137 @@
|
|||||||
//! Provides encapsulated CRUD operations for Listing entities
|
//! Provides encapsulated CRUD operations for Listing entities
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
|
use chrono::{Duration, Utc};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
new_listing::{NewListing, NewListingFields},
|
bind_fields::BindFields,
|
||||||
ListingBase, ListingFields,
|
listing::{
|
||||||
|
Listing, ListingBase, ListingFields, NewListing, PersistedListing, PersistedListingFields,
|
||||||
|
},
|
||||||
|
ListingDbId, ListingType, UserDbId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::{Listing, ListingId, ListingType, UserRowId};
|
|
||||||
|
|
||||||
/// Data Access Object for Listing operations
|
/// Data Access Object for Listing operations
|
||||||
pub struct ListingDAO;
|
pub struct ListingDAO;
|
||||||
|
|
||||||
|
const LISTING_RETURN_FIELDS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"seller_id",
|
||||||
|
"listing_type",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"starts_at",
|
||||||
|
"ends_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"starting_bid",
|
||||||
|
"buy_now_price",
|
||||||
|
"min_increment",
|
||||||
|
"anti_snipe_minutes",
|
||||||
|
"slots_available",
|
||||||
|
];
|
||||||
|
|
||||||
impl ListingDAO {
|
impl ListingDAO {
|
||||||
/// Insert a new listing into the database
|
/// Insert a new listing into the database
|
||||||
pub async fn insert_listing(pool: &SqlitePool, new_listing: &NewListing) -> Result<Listing> {
|
pub async fn insert_listing(
|
||||||
let listing_type = new_listing.listing_type();
|
pool: &SqlitePool,
|
||||||
let base = &new_listing.base;
|
listing: NewListing,
|
||||||
let fields = &new_listing.fields;
|
) -> Result<PersistedListing> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let start_at = now + Into::<Duration>::into(listing.persisted.start_delay);
|
||||||
|
let end_at = start_at + Into::<Duration>::into(listing.persisted.end_delay);
|
||||||
|
|
||||||
let base_query = match listing_type {
|
let binds = binds_for_listing(&listing)
|
||||||
ListingType::BasicAuction => sqlx::query(
|
.push("seller_id", &listing.base.seller_id)
|
||||||
r#"
|
.push("starts_at", &start_at)
|
||||||
INSERT INTO listings (
|
.push("ends_at", &end_at)
|
||||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
.push("created_at", &now)
|
||||||
starting_bid, buy_now_price, min_increment, anti_snipe_minutes
|
.push("updated_at", &now);
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
|
||||||
starting_bid, buy_now_price, min_increment, anti_snipe_minutes
|
|
||||||
"#,
|
|
||||||
),
|
|
||||||
ListingType::MultiSlotAuction => sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO listings (
|
|
||||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
|
||||||
starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
|
||||||
starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes
|
|
||||||
"#,
|
|
||||||
),
|
|
||||||
ListingType::FixedPriceListing => sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO listings (
|
|
||||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
|
||||||
buy_now_price, slots_available
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
|
||||||
buy_now_price, slots_available
|
|
||||||
"#,
|
|
||||||
),
|
|
||||||
ListingType::BlindAuction => sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO listings (
|
|
||||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
|
||||||
starting_bid
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
|
||||||
starting_bid
|
|
||||||
"#,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = base_query
|
let query_str = format!(
|
||||||
.bind(base.seller_id)
|
r#"
|
||||||
.bind(listing_type)
|
INSERT INTO listings ({}) VALUES ({})
|
||||||
.bind(&base.title)
|
RETURNING {}
|
||||||
.bind(&base.description)
|
"#,
|
||||||
.bind(base.starts_at)
|
binds.bind_names().join(", "),
|
||||||
.bind(base.ends_at);
|
binds.bind_placeholders().join(", "),
|
||||||
|
LISTING_RETURN_FIELDS.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
let row = match &fields {
|
let row = binds
|
||||||
NewListingFields::BasicAuction {
|
.bind_to_query(sqlx::query(&query_str))
|
||||||
starting_bid,
|
.fetch_one(pool)
|
||||||
buy_now_price,
|
.await?;
|
||||||
min_increment,
|
Ok(FromRow::from_row(&row)?)
|
||||||
anti_snipe_minutes,
|
}
|
||||||
} => row
|
|
||||||
.bind(starting_bid)
|
|
||||||
.bind(buy_now_price)
|
|
||||||
.bind(min_increment)
|
|
||||||
.bind(anti_snipe_minutes),
|
|
||||||
NewListingFields::MultiSlotAuction {
|
|
||||||
starting_bid,
|
|
||||||
buy_now_price,
|
|
||||||
min_increment,
|
|
||||||
slots_available,
|
|
||||||
anti_snipe_minutes,
|
|
||||||
} => row
|
|
||||||
.bind(starting_bid)
|
|
||||||
.bind(buy_now_price)
|
|
||||||
.bind(min_increment)
|
|
||||||
.bind(slots_available)
|
|
||||||
.bind(anti_snipe_minutes),
|
|
||||||
NewListingFields::FixedPriceListing {
|
|
||||||
buy_now_price,
|
|
||||||
slots_available,
|
|
||||||
} => row.bind(buy_now_price).bind(slots_available),
|
|
||||||
NewListingFields::BlindAuction { starting_bid } => row.bind(starting_bid),
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = row.fetch_one(pool).await?;
|
pub async fn update_listing(
|
||||||
Self::row_to_listing(row)
|
pool: &SqlitePool,
|
||||||
|
listing: PersistedListing,
|
||||||
|
) -> Result<PersistedListing> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let binds = binds_for_listing(&listing).push("updated_at", &now);
|
||||||
|
|
||||||
|
let query_str = format!(
|
||||||
|
r#"
|
||||||
|
UPDATE listings
|
||||||
|
SET {}
|
||||||
|
WHERE id = ?
|
||||||
|
AND seller_id = ?
|
||||||
|
RETURNING {}
|
||||||
|
"#,
|
||||||
|
binds
|
||||||
|
.bind_names()
|
||||||
|
.map(|name| format!("{name} = ?"))
|
||||||
|
.join(", "),
|
||||||
|
LISTING_RETURN_FIELDS.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let row = binds
|
||||||
|
.bind_to_query(sqlx::query(&query_str))
|
||||||
|
.bind(listing.persisted.id)
|
||||||
|
.bind(listing.base.seller_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(FromRow::from_row(&row)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a listing by its ID
|
/// Find a listing by its ID
|
||||||
pub async fn find_by_id(pool: &SqlitePool, listing_id: ListingId) -> Result<Option<Listing>> {
|
pub async fn find_by_id(
|
||||||
let result = sqlx::query("SELECT * FROM listings WHERE id = ?")
|
pool: &SqlitePool,
|
||||||
.bind(listing_id)
|
listing_id: ListingDbId,
|
||||||
.fetch_optional(pool)
|
) -> Result<Option<PersistedListing>> {
|
||||||
.await?;
|
let result = sqlx::query_as(&format!(
|
||||||
|
"SELECT {} FROM listings WHERE id = ?",
|
||||||
|
LISTING_RETURN_FIELDS.join(", ")
|
||||||
|
))
|
||||||
|
.bind(listing_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
result.map(Self::row_to_listing).transpose()
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find all listings by a seller
|
/// Find all listings by a seller
|
||||||
pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserRowId) -> Result<Vec<Listing>> {
|
pub async fn find_by_seller(
|
||||||
let rows =
|
pool: &SqlitePool,
|
||||||
sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
|
seller_id: UserDbId,
|
||||||
.bind(seller_id)
|
) -> Result<Vec<PersistedListing>> {
|
||||||
.fetch_all(pool)
|
let rows = sqlx::query_as(&format!(
|
||||||
.await?;
|
"SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC",
|
||||||
|
LISTING_RETURN_FIELDS.join(", ")
|
||||||
rows
|
))
|
||||||
.into_iter()
|
.bind(seller_id)
|
||||||
.map(Self::row_to_listing)
|
.fetch_all(pool)
|
||||||
.collect::<Result<Vec<_>>>()
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a listing
|
/// Delete a listing
|
||||||
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> {
|
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingDbId) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM listings WHERE id = ?")
|
sqlx::query("DELETE FROM listings WHERE id = ?")
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -140,18 +141,73 @@ impl ListingDAO {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_listing(row: SqliteRow) -> Result<Listing> {
|
fn binds_for_listing<P: Debug + Clone>(listing: &Listing<P>) -> BindFields {
|
||||||
|
BindFields::default()
|
||||||
|
.extend(binds_for_base(&listing.base))
|
||||||
|
.extend(binds_for_fields(&listing.fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binds_for_base(base: &ListingBase) -> BindFields {
|
||||||
|
BindFields::default()
|
||||||
|
.push("title", &base.title)
|
||||||
|
.push("description", &base.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binds_for_fields(fields: &ListingFields) -> BindFields {
|
||||||
|
match fields {
|
||||||
|
ListingFields::BasicAuction {
|
||||||
|
starting_bid,
|
||||||
|
buy_now_price,
|
||||||
|
min_increment,
|
||||||
|
anti_snipe_minutes,
|
||||||
|
} => 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("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("listing_type", &ListingType::FixedPriceListing)
|
||||||
|
.push("buy_now_price", buy_now_price)
|
||||||
|
.push("slots_available", slots_available),
|
||||||
|
ListingFields::BlindAuction { starting_bid } => BindFields::default()
|
||||||
|
.push("listing_type", &ListingType::BlindAuction)
|
||||||
|
.push("starting_bid", starting_bid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRow<'_, SqliteRow> for PersistedListing {
|
||||||
|
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
|
||||||
let listing_type = row.get("listing_type");
|
let listing_type = row.get("listing_type");
|
||||||
|
let persisted = PersistedListingFields {
|
||||||
|
id: row.get("id"),
|
||||||
|
start_at: row.get("starts_at"),
|
||||||
|
end_at: row.get("ends_at"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
};
|
||||||
let base = ListingBase {
|
let base = ListingBase {
|
||||||
id: ListingId::new(row.get("id")),
|
|
||||||
seller_id: row.get("seller_id"),
|
seller_id: row.get("seller_id"),
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
description: row.get("description"),
|
description: row.get("description"),
|
||||||
starts_at: row.get("starts_at"),
|
|
||||||
ends_at: row.get("ends_at"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
updated_at: row.get("updated_at"),
|
|
||||||
};
|
};
|
||||||
let fields = match listing_type {
|
let fields = match listing_type {
|
||||||
ListingType::BasicAuction => ListingFields::BasicAuction {
|
ListingType::BasicAuction => ListingFields::BasicAuction {
|
||||||
@@ -175,6 +231,10 @@ impl ListingDAO {
|
|||||||
starting_bid: row.get("starting_bid"),
|
starting_bid: row.get("starting_bid"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Ok(Listing { base, fields })
|
Ok(PersistedListing {
|
||||||
|
persisted,
|
||||||
|
base,
|
||||||
|
fields,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,78 +3,131 @@
|
|||||||
//! Provides encapsulated CRUD operations for User entities
|
//! Provides encapsulated CRUD operations for User entities
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::SqlitePool;
|
use itertools::Itertools as _;
|
||||||
|
use sqlx::{sqlite::SqliteRow, FromRow, SqlitePool};
|
||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
models::user::{NewUser, User},
|
bind_fields::BindFields,
|
||||||
TelegramUserId, UserRowId,
|
models::user::NewUser,
|
||||||
|
user::{PersistedUser, PersistedUserFields},
|
||||||
|
TelegramUserDbId, UserDbId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Data Access Object for User operations
|
/// Data Access Object for User operations
|
||||||
pub struct UserDAO;
|
pub struct UserDAO;
|
||||||
|
|
||||||
|
const USER_RETURN_FIELDS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"telegram_id",
|
||||||
|
"username",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"is_banned",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
];
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
impl UserDAO {
|
impl UserDAO {
|
||||||
/// Insert a new user into the database
|
/// Insert a new user into the database
|
||||||
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<User> {
|
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<PersistedUser> {
|
||||||
let user = sqlx::query_as::<_, User>(
|
let binds = BindFields::default()
|
||||||
r#"
|
.push("telegram_id", &new_user.telegram_id)
|
||||||
INSERT INTO users (telegram_id, username, display_name)
|
.push("first_name", &new_user.first_name)
|
||||||
VALUES (?, ?, ?)
|
.push("last_name", &new_user.last_name)
|
||||||
RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at
|
.push("username", &new_user.username);
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(new_user.telegram_id)
|
|
||||||
.bind(&new_user.username)
|
|
||||||
.bind(&new_user.display_name)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(user)
|
let query_str = format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users ({})
|
||||||
|
VALUES ({})
|
||||||
|
RETURNING {}
|
||||||
|
"#,
|
||||||
|
binds.bind_names().join(", "),
|
||||||
|
binds.bind_placeholders().join(", "),
|
||||||
|
USER_RETURN_FIELDS.join(", ")
|
||||||
|
);
|
||||||
|
let query = sqlx::query(&query_str);
|
||||||
|
let row = binds.bind_to_query(query).fetch_one(pool).await?;
|
||||||
|
|
||||||
|
Ok(FromRow::from_row(&row)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a user by their ID
|
/// Find a user by their ID
|
||||||
pub async fn find_by_id(pool: &SqlitePool, user_id: UserRowId) -> Result<Option<User>> {
|
pub async fn find_by_id(pool: &SqlitePool, user_id: UserDbId) -> Result<Option<PersistedUser>> {
|
||||||
let user = sqlx::query_as::<_, User>(
|
Ok(sqlx::query_as::<_, PersistedUser>(
|
||||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?"
|
r#"
|
||||||
|
SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?)
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a user by their Telegram ID
|
/// Find a user by their Telegram ID
|
||||||
pub async fn find_by_telegram_id(
|
pub async fn find_by_telegram_id(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
telegram_id: impl Into<TelegramUserId>,
|
telegram_id: impl Into<TelegramUserDbId>,
|
||||||
) -> Result<Option<User>> {
|
) -> Result<Option<PersistedUser>> {
|
||||||
let telegram_id = telegram_id.into();
|
let telegram_id = telegram_id.into();
|
||||||
let user = sqlx::query_as::<_, User>(
|
Ok(sqlx::query_as(
|
||||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?"
|
r#"
|
||||||
|
SELECT id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.bind(telegram_id)
|
.bind(telegram_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_or_create_by_telegram_user(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user: teloxide::types::User,
|
||||||
|
) -> Result<PersistedUser> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let telegram_id = TelegramUserDbId::from(user.id);
|
||||||
|
|
||||||
|
let user = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (telegram_id, username, first_name, last_name)
|
||||||
|
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
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(telegram_id)
|
||||||
|
.bind(user.username)
|
||||||
|
.bind(user.first_name)
|
||||||
|
.bind(user.last_name)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a user's information
|
/// Update a user's information
|
||||||
pub async fn update_user(pool: &SqlitePool, user: &User) -> Result<User> {
|
pub async fn update_user(pool: &SqlitePool, user: &PersistedUser) -> Result<PersistedUser> {
|
||||||
let updated_user = sqlx::query_as::<_, User>(
|
let updated_user = sqlx::query_as::<_, PersistedUser>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET username = ?, display_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP
|
SET username = ?, first_name = ?, last_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at
|
RETURNING id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&user.username)
|
.bind(&user.username)
|
||||||
.bind(&user.display_name)
|
.bind(&user.first_name)
|
||||||
|
.bind(&user.last_name)
|
||||||
.bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite
|
.bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite
|
||||||
.bind(user.id)
|
.bind(user.persisted.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -84,7 +137,7 @@ impl UserDAO {
|
|||||||
/// Set a user's ban status
|
/// Set a user's ban status
|
||||||
pub async fn set_ban_status(
|
pub async fn set_ban_status(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
user_id: UserRowId,
|
user_id: UserDbId,
|
||||||
is_banned: bool,
|
is_banned: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
@@ -97,7 +150,7 @@ impl UserDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a user (soft delete by setting is_banned = true might be better in production)
|
/// Delete a user (soft delete by setting is_banned = true might be better in production)
|
||||||
pub async fn delete_user(pool: &SqlitePool, user_id: UserRowId) -> Result<()> {
|
pub async fn delete_user(pool: &SqlitePool, user_id: UserDbId) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -105,27 +158,24 @@ impl UserDAO {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get or create a user (find by telegram_id, create if not found)
|
impl FromRow<'_, SqliteRow> for PersistedUser {
|
||||||
pub async fn get_or_create_user(
|
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
|
||||||
pool: &SqlitePool,
|
use sqlx::Row as _;
|
||||||
telegram_id: TelegramUserId,
|
|
||||||
username: Option<String>,
|
|
||||||
display_name: Option<String>,
|
|
||||||
) -> Result<User> {
|
|
||||||
// Try to find existing user first
|
|
||||||
if let Some(existing_user) = Self::find_by_telegram_id(pool, telegram_id).await? {
|
|
||||||
return Ok(existing_user);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new user if not found
|
Ok(PersistedUser {
|
||||||
let new_user = NewUser {
|
persisted: PersistedUserFields {
|
||||||
telegram_id,
|
id: row.get("id"),
|
||||||
username,
|
created_at: row.get("created_at"),
|
||||||
display_name,
|
updated_at: row.get("updated_at"),
|
||||||
};
|
},
|
||||||
|
telegram_id: row.get("telegram_id"),
|
||||||
Self::insert_user(pool, &new_user).await
|
username: row.get("username"),
|
||||||
|
first_name: row.get("first_name"),
|
||||||
|
last_name: row.get("last_name"),
|
||||||
|
is_banned: row.get("is_banned"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +183,6 @@ impl UserDAO {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::models::user::NewUser;
|
use crate::db::models::user::NewUser;
|
||||||
use rstest::rstest;
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use teloxide::types::UserId;
|
use teloxide::types::UserId;
|
||||||
|
|
||||||
@@ -157,9 +206,12 @@ mod tests {
|
|||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
|
persisted: (),
|
||||||
telegram_id: 12345.into(),
|
telegram_id: 12345.into(),
|
||||||
|
first_name: "Test User".to_string(),
|
||||||
|
last_name: None,
|
||||||
username: Some("testuser".to_string()),
|
username: Some("testuser".to_string()),
|
||||||
display_name: Some("Test User".to_string()),
|
is_banned: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Insert user
|
// Insert user
|
||||||
@@ -169,16 +221,16 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(inserted_user.telegram_id, 12345.into());
|
assert_eq!(inserted_user.telegram_id, 12345.into());
|
||||||
assert_eq!(inserted_user.username, Some("testuser".to_string()));
|
assert_eq!(inserted_user.username, Some("testuser".to_string()));
|
||||||
assert_eq!(inserted_user.display_name, Some("Test User".to_string()));
|
assert_eq!(inserted_user.first_name, "Test User".to_string());
|
||||||
assert_eq!(inserted_user.is_banned, false);
|
assert_eq!(inserted_user.is_banned, false);
|
||||||
|
|
||||||
// Find by ID
|
// Find by ID
|
||||||
let found_user = UserDAO::find_by_id(&pool, inserted_user.id)
|
let found_user = UserDAO::find_by_id(&pool, inserted_user.persisted.id)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to find user by id")
|
.expect("Failed to find user by id")
|
||||||
.expect("User should be found");
|
.expect("User should be found");
|
||||||
|
|
||||||
assert_eq!(found_user.id, inserted_user.id);
|
assert_eq!(found_user.persisted.id, inserted_user.persisted.id);
|
||||||
assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
|
assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
|
||||||
|
|
||||||
// Find by telegram ID
|
// Find by telegram ID
|
||||||
@@ -187,7 +239,7 @@ mod tests {
|
|||||||
.expect("Failed to find user by telegram_id")
|
.expect("Failed to find user by telegram_id")
|
||||||
.expect("User should be found");
|
.expect("User should be found");
|
||||||
|
|
||||||
assert_eq!(found_by_telegram.id, inserted_user.id);
|
assert_eq!(found_by_telegram.persisted.id, inserted_user.persisted.id);
|
||||||
assert_eq!(found_by_telegram.telegram_id, 12345.into());
|
assert_eq!(found_by_telegram.telegram_id, 12345.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +248,18 @@ mod tests {
|
|||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
|
|
||||||
// First call should create the user
|
// First call should create the user
|
||||||
let user1 = UserDAO::get_or_create_user(
|
let user1 = UserDAO::find_or_create_by_telegram_user(
|
||||||
&pool,
|
&pool,
|
||||||
67890.into(),
|
teloxide::types::User {
|
||||||
Some("newuser".to_string()),
|
id: UserId(67890),
|
||||||
Some("New User".to_string()),
|
is_bot: false,
|
||||||
|
first_name: "New User".to_string(),
|
||||||
|
last_name: None,
|
||||||
|
username: Some("newuser".to_string()),
|
||||||
|
language_code: None,
|
||||||
|
is_premium: false,
|
||||||
|
added_to_attachment_menu: false,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to get or create user");
|
.expect("Failed to get or create user");
|
||||||
@@ -209,58 +268,37 @@ mod tests {
|
|||||||
assert_eq!(user1.username, Some("newuser".to_string()));
|
assert_eq!(user1.username, Some("newuser".to_string()));
|
||||||
|
|
||||||
// Second call should return the same user
|
// Second call should return the same user
|
||||||
let user2 = UserDAO::get_or_create_user(
|
let user2 = UserDAO::find_or_create_by_telegram_user(
|
||||||
&pool,
|
&pool,
|
||||||
67890.into(),
|
teloxide::types::User {
|
||||||
Some("differentusername".to_string()), // This should be ignored
|
id: UserId(67890),
|
||||||
Some("Different Name".to_string()), // This should be ignored
|
is_bot: false,
|
||||||
|
first_name: "New User".to_string(),
|
||||||
|
last_name: None,
|
||||||
|
username: Some("newuser".to_string()),
|
||||||
|
language_code: None,
|
||||||
|
is_premium: false,
|
||||||
|
added_to_attachment_menu: false,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to get or create user");
|
.expect("Failed to get or create user");
|
||||||
|
|
||||||
assert_eq!(user1.id, user2.id);
|
assert_eq!(user1.persisted.id, user2.persisted.id);
|
||||||
assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved
|
assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
#[case(true)]
|
|
||||||
#[case(false)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_ban_status_operations(#[case] is_banned: bool) {
|
|
||||||
let pool = create_test_pool().await;
|
|
||||||
|
|
||||||
let new_user = NewUser {
|
|
||||||
telegram_id: 99999.into(),
|
|
||||||
username: Some("bantest".to_string()),
|
|
||||||
display_name: Some("Ban Test User".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let user = UserDAO::insert_user(&pool, &new_user)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert user");
|
|
||||||
|
|
||||||
// Set ban status
|
|
||||||
UserDAO::set_ban_status(&pool, user.id, is_banned)
|
|
||||||
.await
|
|
||||||
.expect("Failed to set ban status");
|
|
||||||
|
|
||||||
// Verify ban status
|
|
||||||
let updated_user = UserDAO::find_by_id(&pool, user.id)
|
|
||||||
.await
|
|
||||||
.expect("Failed to find user")
|
|
||||||
.expect("User should exist");
|
|
||||||
|
|
||||||
assert_eq!(updated_user.is_banned, is_banned);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_user() {
|
async fn test_update_user() {
|
||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
|
persisted: (),
|
||||||
telegram_id: 55555.into(),
|
telegram_id: 55555.into(),
|
||||||
username: Some("oldname".to_string()),
|
username: Some("oldname".to_string()),
|
||||||
display_name: Some("Old Name".to_string()),
|
first_name: "Old Name".to_string(),
|
||||||
|
last_name: None,
|
||||||
|
is_banned: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut user = UserDAO::insert_user(&pool, &new_user)
|
let mut user = UserDAO::insert_user(&pool, &new_user)
|
||||||
@@ -269,7 +307,7 @@ mod tests {
|
|||||||
|
|
||||||
// Update user information
|
// Update user information
|
||||||
user.username = Some("newname".to_string());
|
user.username = Some("newname".to_string());
|
||||||
user.display_name = Some("New Name".to_string());
|
user.first_name = "New Name".to_string();
|
||||||
user.is_banned = true;
|
user.is_banned = true;
|
||||||
|
|
||||||
let updated_user = UserDAO::update_user(&pool, &user)
|
let updated_user = UserDAO::update_user(&pool, &user)
|
||||||
@@ -277,7 +315,7 @@ mod tests {
|
|||||||
.expect("Failed to update user");
|
.expect("Failed to update user");
|
||||||
|
|
||||||
assert_eq!(updated_user.username, Some("newname".to_string()));
|
assert_eq!(updated_user.username, Some("newname".to_string()));
|
||||||
assert_eq!(updated_user.display_name, Some("New Name".to_string()));
|
assert_eq!(updated_user.first_name, "New Name".to_string());
|
||||||
assert_eq!(updated_user.is_banned, true);
|
assert_eq!(updated_user.is_banned, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,9 +324,12 @@ mod tests {
|
|||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
|
persisted: (),
|
||||||
telegram_id: 77777.into(),
|
telegram_id: 77777.into(),
|
||||||
username: Some("deleteme".to_string()),
|
username: Some("deleteme".to_string()),
|
||||||
display_name: Some("Delete Me".to_string()),
|
first_name: "Delete Me".to_string(),
|
||||||
|
last_name: None,
|
||||||
|
is_banned: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = UserDAO::insert_user(&pool, &new_user)
|
let user = UserDAO::insert_user(&pool, &new_user)
|
||||||
@@ -296,12 +337,12 @@ mod tests {
|
|||||||
.expect("Failed to insert user");
|
.expect("Failed to insert user");
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
UserDAO::delete_user(&pool, user.id)
|
UserDAO::delete_user(&pool, user.persisted.id)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to delete user");
|
.expect("Failed to delete user");
|
||||||
|
|
||||||
// Verify user is gone
|
// Verify user is gone
|
||||||
let not_found = UserDAO::find_by_id(&pool, user.id)
|
let not_found = UserDAO::find_by_id(&pool, user.persisted.id)
|
||||||
.await
|
.await
|
||||||
.expect("Database operation should succeed");
|
.expect("Database operation should succeed");
|
||||||
|
|
||||||
@@ -313,7 +354,7 @@ mod tests {
|
|||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
|
|
||||||
// Try to find a user that doesn't exist
|
// Try to find a user that doesn't exist
|
||||||
let not_found = UserDAO::find_by_id(&pool, UserRowId::new(99999))
|
let not_found = UserDAO::find_by_id(&pool, UserDbId::new(99999))
|
||||||
.await
|
.await
|
||||||
.expect("Database operation should succeed");
|
.expect("Database operation should succeed");
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod bind_fields;
|
||||||
pub mod dao;
|
pub mod dao;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use crate::db::{ListingType, MoneyAmount, UserRowId};
|
use crate::db::{Listing, ListingDbId, ListingType, MoneyAmount, UserDbId};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
/// New listing data for insertion
|
/// New listing data for insertion
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewListing {
|
pub struct DraftListing {
|
||||||
pub base: NewListingBase,
|
pub base: DraftListingBase,
|
||||||
pub fields: NewListingFields,
|
pub fields: DraftListingFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewListing {
|
impl DraftListing {
|
||||||
pub fn listing_type(&self) -> ListingType {
|
pub fn listing_type(&self) -> ListingType {
|
||||||
match &self.fields {
|
match &self.fields {
|
||||||
NewListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
DraftListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
||||||
NewListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
DraftListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
||||||
NewListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
DraftListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
||||||
NewListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
DraftListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewListingBase {
|
pub struct DraftListingBase {
|
||||||
pub seller_id: UserRowId,
|
pub seller_id: UserDbId,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub starts_at: DateTime<Utc>,
|
pub starts_at: DateTime<Utc>,
|
||||||
@@ -30,7 +30,7 @@ pub struct NewListingBase {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub enum NewListingFields {
|
pub enum DraftListingFields {
|
||||||
BasicAuction {
|
BasicAuction {
|
||||||
starting_bid: MoneyAmount,
|
starting_bid: MoneyAmount,
|
||||||
buy_now_price: Option<MoneyAmount>,
|
buy_now_price: Option<MoneyAmount>,
|
||||||
@@ -53,10 +53,18 @@ pub enum NewListingFields {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<Listing> for DraftListing {
|
||||||
|
fn from(listing: Listing) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Some(listing.base.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
impl NewListingBase {
|
impl DraftListingBase {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
seller_id: UserRowId,
|
seller_id: UserDbId,
|
||||||
title: String,
|
title: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
starts_at: DateTime<Utc>,
|
starts_at: DateTime<Utc>,
|
||||||
@@ -78,10 +86,10 @@ impl NewListingBase {
|
|||||||
buy_now_price: Option<MoneyAmount>,
|
buy_now_price: Option<MoneyAmount>,
|
||||||
min_increment: MoneyAmount,
|
min_increment: MoneyAmount,
|
||||||
anti_snipe_minutes: Option<i32>,
|
anti_snipe_minutes: Option<i32>,
|
||||||
) -> NewListing {
|
) -> DraftListing {
|
||||||
NewListing {
|
DraftListing {
|
||||||
base: self,
|
base: self,
|
||||||
fields: NewListingFields::BasicAuction {
|
fields: DraftListingFields::BasicAuction {
|
||||||
starting_bid,
|
starting_bid,
|
||||||
buy_now_price,
|
buy_now_price,
|
||||||
min_increment,
|
min_increment,
|
||||||
@@ -98,10 +106,10 @@ impl NewListingBase {
|
|||||||
min_increment: Option<MoneyAmount>,
|
min_increment: Option<MoneyAmount>,
|
||||||
slots_available: i32,
|
slots_available: i32,
|
||||||
anti_snipe_minutes: i32,
|
anti_snipe_minutes: i32,
|
||||||
) -> NewListing {
|
) -> DraftListing {
|
||||||
NewListing {
|
DraftListing {
|
||||||
base: self,
|
base: self,
|
||||||
fields: NewListingFields::MultiSlotAuction {
|
fields: DraftListingFields::MultiSlotAuction {
|
||||||
starting_bid,
|
starting_bid,
|
||||||
buy_now_price,
|
buy_now_price,
|
||||||
min_increment,
|
min_increment,
|
||||||
@@ -116,10 +124,10 @@ impl NewListingBase {
|
|||||||
self,
|
self,
|
||||||
buy_now_price: MoneyAmount,
|
buy_now_price: MoneyAmount,
|
||||||
slots_available: i32,
|
slots_available: i32,
|
||||||
) -> NewListing {
|
) -> DraftListing {
|
||||||
NewListing {
|
DraftListing {
|
||||||
base: self,
|
base: self,
|
||||||
fields: NewListingFields::FixedPriceListing {
|
fields: DraftListingFields::FixedPriceListing {
|
||||||
buy_now_price,
|
buy_now_price,
|
||||||
slots_available,
|
slots_available,
|
||||||
},
|
},
|
||||||
@@ -127,10 +135,10 @@ impl NewListingBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new blind auction listing
|
/// Create a new blind auction listing
|
||||||
pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> NewListing {
|
pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> DraftListing {
|
||||||
NewListing {
|
DraftListing {
|
||||||
base: self,
|
base: self,
|
||||||
fields: NewListingFields::BlindAuction { starting_bid },
|
fields: DraftListingFields::BlindAuction { starting_bid },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,33 +9,59 @@
|
|||||||
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
||||||
//! Database mapping is handled through `ListingRow` with conversion traits.
|
//! Database mapping is handled through `ListingRow` with conversion traits.
|
||||||
|
|
||||||
use super::listing_type::ListingType;
|
use crate::db::{ListingDbId, ListingDuration, MoneyAmount, UserDbId};
|
||||||
use crate::db::{ListingId, MoneyAmount, UserRowId};
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
pub type NewListing = Listing<NewListingFields>;
|
||||||
|
pub type PersistedListing = Listing<PersistedListingFields>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
pub struct PersistedListingFields {
|
||||||
|
pub id: ListingDbId,
|
||||||
|
pub start_at: DateTime<Utc>,
|
||||||
|
pub end_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)]
|
||||||
|
pub struct NewListingFields {
|
||||||
|
pub start_delay: ListingDuration,
|
||||||
|
pub end_delay: ListingDuration,
|
||||||
|
}
|
||||||
|
|
||||||
/// Main listing/auction entity
|
/// Main listing/auction entity
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct Listing {
|
pub struct Listing<P: Debug + Clone> {
|
||||||
|
pub persisted: P,
|
||||||
pub base: ListingBase,
|
pub base: ListingBase,
|
||||||
pub fields: ListingFields,
|
pub fields: ListingFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common fields shared by all listing types
|
/// Common fields shared by all listing types
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct ListingBase {
|
pub struct ListingBase {
|
||||||
pub id: ListingId,
|
pub seller_id: UserDbId,
|
||||||
pub seller_id: UserRowId,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub starts_at: DateTime<Utc>,
|
|
||||||
pub ends_at: DateTime<Utc>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
impl ListingBase {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn with_fields(self, fields: ListingFields) -> NewListing {
|
||||||
|
Listing {
|
||||||
|
persisted: NewListingFields::default(),
|
||||||
|
base: self,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub enum ListingFields {
|
pub enum ListingFields {
|
||||||
BasicAuction {
|
BasicAuction {
|
||||||
@@ -60,26 +86,11 @@ pub enum ListingFields {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
impl Listing {
|
|
||||||
/// Get the listing type as an enum value
|
|
||||||
pub fn listing_type(&self) -> ListingType {
|
|
||||||
match &self.fields {
|
|
||||||
ListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
|
||||||
ListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
|
||||||
ListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
|
||||||
ListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId};
|
|
||||||
use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::{Duration, Utc};
|
use crate::db::ListingDbId;
|
||||||
|
use crate::db::{ListingDAO, TelegramUserDbId};
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -108,290 +119,64 @@ mod tests {
|
|||||||
/// Create a test user using UserDAO and return their ID
|
/// Create a test user using UserDAO and return their ID
|
||||||
async fn create_test_user(
|
async fn create_test_user(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
telegram_id: TelegramUserId,
|
telegram_id: TelegramUserDbId,
|
||||||
username: Option<&str>,
|
username: Option<&str>,
|
||||||
) -> UserRowId {
|
) -> UserDbId {
|
||||||
use crate::db::{models::user::NewUser, UserDAO};
|
use crate::db::{models::user::NewUser, UserDAO};
|
||||||
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
|
persisted: (),
|
||||||
telegram_id,
|
telegram_id,
|
||||||
|
first_name: "Test User".to_string(),
|
||||||
|
last_name: None,
|
||||||
username: username.map(|s| s.to_string()),
|
username: username.map(|s| s.to_string()),
|
||||||
display_name: username.map(|s| s.to_string()),
|
is_banned: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = UserDAO::insert_user(pool, &new_user)
|
let user = UserDAO::insert_user(pool, &new_user)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create test user");
|
.expect("Failed to create test user");
|
||||||
user.id
|
user.persisted.id
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a listing using ListingDAO by ID
|
|
||||||
async fn fetch_listing_using_dao(pool: &SqlitePool, id: ListingId) -> Listing {
|
|
||||||
use crate::db::ListingDAO;
|
|
||||||
|
|
||||||
ListingDAO::find_by_id(pool, id)
|
|
||||||
.await
|
|
||||||
.expect("Failed to fetch listing using DAO")
|
|
||||||
.expect("Listing should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_basic_auction_crud() {
|
|
||||||
let pool = create_test_pool().await;
|
|
||||||
let seller_id = create_test_user(&pool, 12345.into(), Some("testuser")).await;
|
|
||||||
|
|
||||||
// Create a basic auction listing
|
|
||||||
let starts_at = Utc::now();
|
|
||||||
let ends_at = starts_at + Duration::hours(24);
|
|
||||||
|
|
||||||
let new_listing = build_base_listing(
|
|
||||||
seller_id,
|
|
||||||
"Test Basic Auction",
|
|
||||||
Some("A test auction for basic functionality"),
|
|
||||||
)
|
|
||||||
.new_basic_auction(
|
|
||||||
MoneyAmount::from_str("10.00").unwrap(),
|
|
||||||
Some(MoneyAmount::from_str("100.00").unwrap()),
|
|
||||||
MoneyAmount::from_str("1.00").unwrap(),
|
|
||||||
Some(5),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert using DAO
|
|
||||||
let actual_id = ListingDAO::insert_listing(&pool, &new_listing)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert listing")
|
|
||||||
.base
|
|
||||||
.id;
|
|
||||||
|
|
||||||
// Fetch back from database using DAO
|
|
||||||
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
||||||
|
|
||||||
// Verify the round trip worked correctly
|
|
||||||
match reconstructed_listing.fields {
|
|
||||||
ListingFields::BasicAuction {
|
|
||||||
starting_bid,
|
|
||||||
buy_now_price,
|
|
||||||
min_increment,
|
|
||||||
anti_snipe_minutes,
|
|
||||||
} => {
|
|
||||||
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
||||||
assert_eq!(reconstructed_listing.base.title, "Test Basic Auction");
|
|
||||||
assert_eq!(
|
|
||||||
reconstructed_listing.base.description,
|
|
||||||
Some("A test auction for basic functionality".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
buy_now_price,
|
|
||||||
Some(MoneyAmount::from_str("100.00").unwrap())
|
|
||||||
);
|
|
||||||
assert_eq!(min_increment, MoneyAmount::from_str("1.00").unwrap());
|
|
||||||
assert_eq!(anti_snipe_minutes, Some(5));
|
|
||||||
assert_timestamps_approx_eq_default!(
|
|
||||||
reconstructed_listing.base.starts_at,
|
|
||||||
starts_at
|
|
||||||
);
|
|
||||||
assert_timestamps_approx_eq_default!(reconstructed_listing.base.ends_at, ends_at);
|
|
||||||
}
|
|
||||||
_ => panic!("Expected BasicAuction, got different variant"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_base_listing(
|
fn build_base_listing(
|
||||||
seller_id: UserRowId,
|
seller_id: UserDbId,
|
||||||
title: &str,
|
title: impl Into<String>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
) -> NewListingBase {
|
) -> ListingBase {
|
||||||
NewListingBase {
|
ListingBase {
|
||||||
seller_id,
|
seller_id,
|
||||||
title: title.to_string(),
|
title: title.into(),
|
||||||
description: description.map(|s| s.to_string()),
|
description: description.map(|s| s.to_string()),
|
||||||
starts_at: Utc::now(),
|
|
||||||
ends_at: Utc::now() + Duration::hours(24),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_multi_slot_auction_crud() {
|
|
||||||
let pool = create_test_pool().await;
|
|
||||||
let seller_id = create_test_user(&pool, 67890.into(), Some("multislotuser")).await;
|
|
||||||
let listing = build_base_listing(seller_id, "Test Multi-Slot Auction", None)
|
|
||||||
.new_multi_slot_auction(
|
|
||||||
MoneyAmount::from_str("10.00").unwrap(),
|
|
||||||
MoneyAmount::from_str("50.00").unwrap(),
|
|
||||||
Some(MoneyAmount::from_str("2.50").unwrap()),
|
|
||||||
5,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert using DAO
|
|
||||||
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert listing")
|
|
||||||
.base
|
|
||||||
.id;
|
|
||||||
|
|
||||||
// Fetch back from database using DAO
|
|
||||||
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
||||||
|
|
||||||
// Verify the round trip worked correctly
|
|
||||||
match reconstructed_listing.fields {
|
|
||||||
ListingFields::MultiSlotAuction {
|
|
||||||
starting_bid,
|
|
||||||
buy_now_price,
|
|
||||||
min_increment,
|
|
||||||
slots_available,
|
|
||||||
anti_snipe_minutes,
|
|
||||||
} => {
|
|
||||||
let reconstructed_base = reconstructed_listing.base;
|
|
||||||
assert_eq!(reconstructed_base.seller_id, seller_id);
|
|
||||||
assert_eq!(reconstructed_base.title, "Test Multi-Slot Auction");
|
|
||||||
assert_eq!(reconstructed_base.description, None);
|
|
||||||
assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap());
|
|
||||||
assert_eq!(buy_now_price, MoneyAmount::from_str("50.00").unwrap());
|
|
||||||
assert_eq!(min_increment, Some(MoneyAmount::from_str("2.50").unwrap()));
|
|
||||||
assert_eq!(slots_available, 5);
|
|
||||||
assert_eq!(anti_snipe_minutes, 10);
|
|
||||||
assert_listing_timestamps_approx_eq!(reconstructed_base, listing.base);
|
|
||||||
}
|
|
||||||
_ => panic!("Expected MultiSlotAuction, got different variant"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_fixed_price_listing_crud() {
|
|
||||||
let pool = create_test_pool().await;
|
|
||||||
let seller_id = create_test_user(&pool, 11111.into(), Some("fixedpriceuser")).await;
|
|
||||||
|
|
||||||
let listing = build_base_listing(
|
|
||||||
seller_id,
|
|
||||||
"Test Fixed Price Item",
|
|
||||||
Some("Fixed price sale with multiple slots"),
|
|
||||||
)
|
|
||||||
.new_fixed_price_listing(MoneyAmount::from_str("25.99").unwrap(), 3);
|
|
||||||
|
|
||||||
// Insert using DAO
|
|
||||||
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert listing")
|
|
||||||
.base
|
|
||||||
.id;
|
|
||||||
|
|
||||||
// Fetch back from database using DAO
|
|
||||||
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
||||||
|
|
||||||
// Verify the round trip worked correctly
|
|
||||||
match reconstructed_listing.fields {
|
|
||||||
ListingFields::FixedPriceListing {
|
|
||||||
buy_now_price,
|
|
||||||
slots_available,
|
|
||||||
} => {
|
|
||||||
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
||||||
assert_eq!(reconstructed_listing.base.title, "Test Fixed Price Item");
|
|
||||||
assert_eq!(
|
|
||||||
listing.base.description,
|
|
||||||
Some("Fixed price sale with multiple slots".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(buy_now_price, MoneyAmount::from_str("25.99").unwrap());
|
|
||||||
assert_eq!(slots_available, 3);
|
|
||||||
assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base);
|
|
||||||
}
|
|
||||||
_ => panic!("Expected FixedPriceListing, got different variant"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_blind_auction_crud() {
|
|
||||||
let pool = create_test_pool().await;
|
|
||||||
let seller_id = create_test_user(&pool, 99999.into(), Some("blinduser")).await;
|
|
||||||
|
|
||||||
let listing = build_base_listing(
|
|
||||||
seller_id,
|
|
||||||
"Test Blind Auction",
|
|
||||||
Some("Seller chooses winner"),
|
|
||||||
)
|
|
||||||
.new_blind_auction(MoneyAmount::from_str("100.00").unwrap());
|
|
||||||
|
|
||||||
// Insert using DAO
|
|
||||||
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert listing")
|
|
||||||
.base
|
|
||||||
.id;
|
|
||||||
|
|
||||||
// Fetch back from database using DAO
|
|
||||||
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
||||||
|
|
||||||
// Verify the round trip worked correctly
|
|
||||||
match reconstructed_listing.fields {
|
|
||||||
ListingFields::BlindAuction { starting_bid } => {
|
|
||||||
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
||||||
assert_eq!(reconstructed_listing.base.title, "Test Blind Auction");
|
|
||||||
assert_eq!(
|
|
||||||
reconstructed_listing.base.description,
|
|
||||||
Some("Seller chooses winner".to_string())
|
|
||||||
);
|
|
||||||
assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base);
|
|
||||||
assert_eq!(starting_bid, MoneyAmount::from_str("100.00").unwrap());
|
|
||||||
}
|
|
||||||
_ => panic!("Expected BlindAuction, got different variant"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[case("10.50", "100.00", "1.00")]
|
#[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })]
|
||||||
#[case("0.00", "50.00", "0.25")]
|
#[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("25.75", "999.99", "5.50")]
|
#[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() })]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_money_amount_precision_in_listings(
|
async fn test_blind_auction_crud(#[case] fields: ListingFields) {
|
||||||
#[case] starting_bid_str: &str,
|
|
||||||
#[case] buy_now_price_str: &str,
|
|
||||||
#[case] min_increment_str: &str,
|
|
||||||
) {
|
|
||||||
let pool = create_test_pool().await;
|
let pool = create_test_pool().await;
|
||||||
let seller_id = create_test_user(&pool, 55555.into(), Some("precisionuser")).await;
|
let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await;
|
||||||
|
let new_listing = build_base_listing(seller_id, "Test Auction", Some("Test description"))
|
||||||
let listing = build_base_listing(seller_id, "Precision Test Auction", None)
|
.with_fields(fields);
|
||||||
.new_basic_auction(
|
|
||||||
MoneyAmount::from_str(starting_bid_str).unwrap(),
|
|
||||||
Some(MoneyAmount::from_str(buy_now_price_str).unwrap()),
|
|
||||||
MoneyAmount::from_str(min_increment_str).unwrap(),
|
|
||||||
Some(5),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert using DAO
|
// Insert using DAO
|
||||||
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone())
|
||||||
.await
|
.await
|
||||||
.expect("Failed to insert listing")
|
.expect("Failed to insert listing");
|
||||||
.base
|
|
||||||
.id;
|
|
||||||
|
|
||||||
// Fetch back from database using DAO
|
assert_eq!(created_listing.base, new_listing.base);
|
||||||
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
assert_eq!(created_listing.fields, new_listing.fields);
|
||||||
|
|
||||||
// Verify precision is maintained
|
let read_listing = ListingDAO::find_by_id(&pool, created_listing.persisted.id)
|
||||||
match reconstructed_listing.fields {
|
.await
|
||||||
ListingFields::BasicAuction {
|
.expect("Failed to find listing")
|
||||||
starting_bid,
|
.expect("Listing should exist");
|
||||||
buy_now_price,
|
|
||||||
min_increment,
|
assert_eq!(read_listing, created_listing);
|
||||||
anti_snipe_minutes,
|
|
||||||
} => {
|
|
||||||
assert_eq!(
|
|
||||||
starting_bid,
|
|
||||||
MoneyAmount::from_str(starting_bid_str).unwrap()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
buy_now_price,
|
|
||||||
Some(MoneyAmount::from_str(buy_now_price_str).unwrap())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
min_increment,
|
|
||||||
MoneyAmount::from_str(min_increment_str).unwrap(),
|
|
||||||
);
|
|
||||||
assert_eq!(anti_snipe_minutes, Some(5));
|
|
||||||
}
|
|
||||||
_ => panic!("Expected BasicAuction"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/// Types of listings supported by the platform
|
/// Types of listings supported by the platform
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]
|
#[derive(Debug, Clone, PartialEq, Eq, Copy, sqlx::Type)]
|
||||||
#[sqlx(type_name = "TEXT")]
|
#[sqlx(type_name = "TEXT")]
|
||||||
#[sqlx(rename_all = "snake_case")]
|
#[sqlx(rename_all = "snake_case")]
|
||||||
pub enum ListingType {
|
pub enum ListingType {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ pub mod bid;
|
|||||||
pub mod listing;
|
pub mod listing;
|
||||||
pub mod listing_media;
|
pub mod listing_media;
|
||||||
pub mod listing_type;
|
pub mod listing_type;
|
||||||
pub mod new_listing;
|
|
||||||
pub mod proxy_bid;
|
pub mod proxy_bid;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod user_settings;
|
pub mod user_settings;
|
||||||
|
|
||||||
// Re-export all types for easy access
|
// Re-export all types for easy access
|
||||||
pub use listing::*;
|
|
||||||
pub use listing_type::*;
|
pub use listing_type::*;
|
||||||
pub use user::*;
|
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::db::{TelegramUserId, UserRowId};
|
use crate::db::{TelegramUserDbId, UserDbId};
|
||||||
|
|
||||||
|
pub type PersistedUser = User<PersistedUserFields>;
|
||||||
|
pub type NewUser = User<()>;
|
||||||
|
|
||||||
/// Core user information
|
/// Core user information
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct User {
|
pub struct User<P: Debug + Clone> {
|
||||||
pub id: UserRowId,
|
pub persisted: P,
|
||||||
pub telegram_id: TelegramUserId,
|
pub telegram_id: TelegramUserDbId,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: Option<String>,
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub display_name: Option<String>,
|
|
||||||
// SQLite stores booleans as INTEGER (0/1), sqlx FromRow handles the conversion automatically
|
|
||||||
pub is_banned: bool,
|
pub is_banned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PersistedUserFields {
|
||||||
|
pub id: UserDbId,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New user data for insertion
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NewUser {
|
|
||||||
pub telegram_id: TelegramUserId,
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use std::fmt;
|
|||||||
|
|
||||||
/// Type-safe wrapper for listing IDs
|
/// Type-safe wrapper for listing IDs
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct ListingId(i64);
|
pub struct ListingDbId(i64);
|
||||||
|
|
||||||
impl ListingId {
|
impl ListingDbId {
|
||||||
/// Create a new ListingId from an i64
|
/// Create a new ListingId from an i64
|
||||||
pub fn new(id: i64) -> Self {
|
pub fn new(id: i64) -> Self {
|
||||||
Self(id)
|
Self(id)
|
||||||
@@ -25,26 +25,26 @@ impl ListingId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<i64> for ListingId {
|
impl From<i64> for ListingDbId {
|
||||||
fn from(id: i64) -> Self {
|
fn from(id: i64) -> Self {
|
||||||
Self(id)
|
Self(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ListingId> for i64 {
|
impl From<ListingDbId> for i64 {
|
||||||
fn from(listing_id: ListingId) -> Self {
|
fn from(listing_id: ListingDbId) -> Self {
|
||||||
listing_id.0
|
listing_id.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ListingId {
|
impl fmt::Display for ListingDbId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.0)
|
write!(f, "{}", self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLx implementations for database compatibility
|
// SQLx implementations for database compatibility
|
||||||
impl Type<Sqlite> for ListingId {
|
impl Type<Sqlite> for ListingDbId {
|
||||||
fn type_info() -> SqliteTypeInfo {
|
fn type_info() -> SqliteTypeInfo {
|
||||||
<i64 as Type<Sqlite>>::type_info()
|
<i64 as Type<Sqlite>>::type_info()
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ impl Type<Sqlite> for ListingId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'q> Encode<'q, Sqlite> for ListingId {
|
impl<'q> Encode<'q, Sqlite> for ListingDbId {
|
||||||
fn encode_by_ref(
|
fn encode_by_ref(
|
||||||
&self,
|
&self,
|
||||||
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
||||||
@@ -63,7 +63,7 @@ impl<'q> Encode<'q, Sqlite> for ListingId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'r> Decode<'r, Sqlite> for ListingId {
|
impl<'r> Decode<'r, Sqlite> for ListingDbId {
|
||||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||||
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
||||||
Ok(Self(id))
|
Ok(Self(id))
|
||||||
7
src/db/types/db_id/mod.rs
Normal file
7
src/db/types/db_id/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod listing_db_id;
|
||||||
|
mod telegram_user_db_id;
|
||||||
|
mod user_db_id;
|
||||||
|
|
||||||
|
pub use listing_db_id::ListingDbId;
|
||||||
|
pub use telegram_user_db_id::TelegramUserDbId;
|
||||||
|
pub use user_db_id::UserDbId;
|
||||||
@@ -10,10 +10,10 @@ use sqlx::{
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Type-safe wrapper for user IDs
|
/// Type-safe wrapper for user IDs
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct TelegramUserId(teloxide::types::UserId);
|
pub struct TelegramUserDbId(teloxide::types::UserId);
|
||||||
|
|
||||||
impl TelegramUserId {
|
impl TelegramUserDbId {
|
||||||
/// Create a new TelegramUserId
|
/// Create a new TelegramUserId
|
||||||
/// from an i64
|
/// from an i64
|
||||||
pub fn new(id: teloxide::types::UserId) -> Self {
|
pub fn new(id: teloxide::types::UserId) -> Self {
|
||||||
@@ -26,32 +26,32 @@ impl TelegramUserId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<teloxide::types::UserId> for TelegramUserId {
|
impl From<teloxide::types::UserId> for TelegramUserDbId {
|
||||||
fn from(id: teloxide::types::UserId) -> Self {
|
fn from(id: teloxide::types::UserId) -> Self {
|
||||||
Self(id)
|
Self(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<u64> for TelegramUserId {
|
impl From<u64> for TelegramUserDbId {
|
||||||
fn from(id: u64) -> Self {
|
fn from(id: u64) -> Self {
|
||||||
Self(teloxide::types::UserId(id))
|
Self(teloxide::types::UserId(id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<TelegramUserId> for teloxide::types::UserId {
|
impl From<TelegramUserDbId> for teloxide::types::UserId {
|
||||||
fn from(user_id: TelegramUserId) -> Self {
|
fn from(user_id: TelegramUserDbId) -> Self {
|
||||||
user_id.0
|
user_id.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for TelegramUserId {
|
impl fmt::Display for TelegramUserDbId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.0)
|
write!(f, "{}", self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLx implementations for database compatibility
|
// SQLx implementations for database compatibility
|
||||||
impl Type<Sqlite> for TelegramUserId {
|
impl Type<Sqlite> for TelegramUserDbId {
|
||||||
fn type_info() -> SqliteTypeInfo {
|
fn type_info() -> SqliteTypeInfo {
|
||||||
<i64 as Type<Sqlite>>::type_info()
|
<i64 as Type<Sqlite>>::type_info()
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ impl Type<Sqlite> for TelegramUserId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'q> Encode<'q, Sqlite> for TelegramUserId {
|
impl<'q> Encode<'q, Sqlite> for TelegramUserDbId {
|
||||||
fn encode_by_ref(
|
fn encode_by_ref(
|
||||||
&self,
|
&self,
|
||||||
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
||||||
@@ -70,7 +70,7 @@ impl<'q> Encode<'q, Sqlite> for TelegramUserId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'r> Decode<'r, Sqlite> for TelegramUserId {
|
impl<'r> Decode<'r, Sqlite> for TelegramUserDbId {
|
||||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||||
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
||||||
Ok(Self(teloxide::types::UserId(id as u64)))
|
Ok(Self(teloxide::types::UserId(id as u64)))
|
||||||
@@ -3,16 +3,17 @@
|
|||||||
//! This newtype prevents accidentally mixing up user IDs with other ID types
|
//! This newtype prevents accidentally mixing up user IDs with other ID types
|
||||||
//! while maintaining compatibility with the database layer through SQLx traits.
|
//! while maintaining compatibility with the database layer through SQLx traits.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
|
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
|
||||||
};
|
};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Type-safe wrapper for user IDs
|
/// Type-safe wrapper for user IDs
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct UserRowId(i64);
|
pub struct UserDbId(i64);
|
||||||
|
|
||||||
impl UserRowId {
|
impl UserDbId {
|
||||||
/// Create a new UserId from an i64
|
/// Create a new UserId from an i64
|
||||||
pub fn new(id: i64) -> Self {
|
pub fn new(id: i64) -> Self {
|
||||||
Self(id)
|
Self(id)
|
||||||
@@ -24,26 +25,26 @@ impl UserRowId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<i64> for UserRowId {
|
impl From<i64> for UserDbId {
|
||||||
fn from(id: i64) -> Self {
|
fn from(id: i64) -> Self {
|
||||||
Self(id)
|
Self(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<UserRowId> for i64 {
|
impl From<UserDbId> for i64 {
|
||||||
fn from(user_id: UserRowId) -> Self {
|
fn from(user_id: UserDbId) -> Self {
|
||||||
user_id.0
|
user_id.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for UserRowId {
|
impl fmt::Display for UserDbId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.0)
|
write!(f, "{}", self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLx implementations for database compatibility
|
// SQLx implementations for database compatibility
|
||||||
impl Type<Sqlite> for UserRowId {
|
impl Type<Sqlite> for UserDbId {
|
||||||
fn type_info() -> SqliteTypeInfo {
|
fn type_info() -> SqliteTypeInfo {
|
||||||
<i64 as Type<Sqlite>>::type_info()
|
<i64 as Type<Sqlite>>::type_info()
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ impl Type<Sqlite> for UserRowId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'q> Encode<'q, Sqlite> for UserRowId {
|
impl<'q> Encode<'q, Sqlite> for UserDbId {
|
||||||
fn encode_by_ref(
|
fn encode_by_ref(
|
||||||
&self,
|
&self,
|
||||||
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
||||||
@@ -62,7 +63,7 @@ impl<'q> Encode<'q, Sqlite> for UserRowId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'r> Decode<'r, Sqlite> for UserRowId {
|
impl<'r> Decode<'r, Sqlite> for UserDbId {
|
||||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||||
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
||||||
Ok(Self(id))
|
Ok(Self(id))
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
mod currency_type;
|
mod currency_type;
|
||||||
|
mod db_id;
|
||||||
mod listing_duration;
|
mod listing_duration;
|
||||||
mod listing_id;
|
|
||||||
mod money_amount;
|
mod money_amount;
|
||||||
mod telegram_user_id;
|
|
||||||
mod user_row_id;
|
|
||||||
|
|
||||||
// Re-export all types for easy access
|
// Re-export all types for easy access
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub use currency_type::*;
|
pub use currency_type::*;
|
||||||
|
pub use db_id::*;
|
||||||
pub use listing_duration::*;
|
pub use listing_duration::*;
|
||||||
pub use listing_id::*;
|
|
||||||
pub use money_amount::*;
|
pub use money_amount::*;
|
||||||
pub use telegram_user_id::*;
|
|
||||||
pub use user_row_id::*;
|
|
||||||
|
|||||||
@@ -87,56 +87,6 @@ macro_rules! assert_timestamps_approx_eq_default {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assert that the `starts_at` and `ends_at` fields of two structs are approximately equal.
|
|
||||||
///
|
|
||||||
/// This macro is specifically designed for comparing listing timestamps where small
|
|
||||||
/// variations in timing are expected. Uses a default epsilon of 1 second.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use chrono::Utc;
|
|
||||||
/// use crate::test_utils::assert_listing_timestamps_approx_eq;
|
|
||||||
///
|
|
||||||
/// let original_listing = /* some listing */;
|
|
||||||
/// let reconstructed_listing = /* reconstructed from DB */;
|
|
||||||
///
|
|
||||||
/// // Compare both starts_at and ends_at with default 1s epsilon
|
|
||||||
/// assert_listing_timestamps_approx_eq!(
|
|
||||||
/// original_listing.base,
|
|
||||||
/// reconstructed_listing.base
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! assert_listing_timestamps_approx_eq {
|
|
||||||
($left:expr, $right:expr) => {
|
|
||||||
$crate::assert_timestamps_approx_eq_default!(
|
|
||||||
$left.starts_at,
|
|
||||||
$right.starts_at,
|
|
||||||
"starts_at timestamps don't match"
|
|
||||||
);
|
|
||||||
$crate::assert_timestamps_approx_eq_default!(
|
|
||||||
$left.ends_at,
|
|
||||||
$right.ends_at,
|
|
||||||
"ends_at timestamps don't match"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
($left:expr, $right:expr, $epsilon:expr) => {
|
|
||||||
$crate::assert_timestamps_approx_eq!(
|
|
||||||
$left.starts_at,
|
|
||||||
$right.starts_at,
|
|
||||||
$epsilon,
|
|
||||||
"starts_at timestamps don't match"
|
|
||||||
);
|
|
||||||
$crate::assert_timestamps_approx_eq!(
|
|
||||||
$left.ends_at,
|
|
||||||
$right.ends_at,
|
|
||||||
$epsilon,
|
|
||||||
"ends_at timestamps don't match"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
|
|||||||
Reference in New Issue
Block a user