Compare commits

...

26 Commits

Author SHA1 Message Date
Dylan Knutson
4396d367a8 active listing constraint for bidding 2025-09-10 23:51:01 +00:00
Dylan Knutson
212a72c511 send messages to winners / losers of an auction 2025-09-10 03:53:48 +00:00
Dylan Knutson
a955acbdce add listing expiry checker task 2025-09-10 03:15:58 +00:00
Dylan Knutson
7eada9588c tests for bidding when auction is expired 2025-09-09 02:45:56 +00:00
Dylan Knutson
0d18016993 integration tests for confirm bid amount 2025-09-09 01:40:36 +00:00
Dylan Knutson
df137403a7 id renames 2025-09-05 22:48:17 +00:00
Dylan Knutson
1c7131d801 first integration test 2025-09-05 22:33:03 +00:00
Dylan Knutson
a40ac5f345 move target into state 2025-09-05 22:17:30 +00:00
Dylan Knutson
af5b8883af mocakble message sender trait 2025-09-05 21:48:52 +00:00
Dylan Knutson
da7e59fe0f create App struct 2025-09-05 03:50:09 +00:00
Dylan Knutson
9ad562a4b2 basic scaffold for placing bids 2025-09-03 00:28:46 +00:00
Dylan Knutson
c53dccbea2 error handler wrapper helper 2025-09-02 19:55:15 +00:00
Dylan Knutson
8610b4cc52 handler wrapper for catching error 2025-09-02 19:11:57 +00:00
Dylan Knutson
f74c3502d6 basic listing stuff 2025-09-02 01:47:45 +00:00
Dylan Knutson
da1fd1027f more handler refactoring 2025-08-31 00:04:18 +00:00
Dylan Knutson
461ce3aba4 Update new_listing and my_listings functionality 2025-08-30 18:41:42 +00:00
Dylan Knutson
a39dd01452 Refactor bot commands and database models
- Update my_listings command structure and keyboard handling
- Enhance new_listing workflow with improved callbacks and handlers
- Refactor database user model and keyboard utilities
- Add new handler utilities module
- Update main bot configuration and start command
2025-08-30 11:04:40 -07:00
Dylan Knutson
65a50b05e2 feat: Add inline keyboard support to auction listing inline queries
- Add interactive inline keyboard with bid, watch, and share buttons
- Implement separate inline query handler before dialogue system
- Fix dispatcher structure to handle both inline queries and regular messages
- Add dynamic bot username fetching for deep links
- Support rich formatted auction listing messages with pricing info
- Handle forward_listing:ID pattern for sharing specific auctions

Resolves inline query handling and adds interactive auction sharing
2025-08-30 15:37:02 +00:00
Dylan Knutson
c9a824dd88 cargo clippy 2025-08-30 05:50:44 +00:00
Dylan Knutson
5d7a5b26c1 feat: enhance UX with listing type selection and main menu improvements
- Add listing type selection to new listing wizard
  - Create SelectingListingType state for choosing between 4 listing types
  - Add ListingTypeKeyboardButtons with clear descriptions
  - Support BasicAuction, MultiSlotAuction, FixedPriceListing, BlindAuction
  - Update handlers to start with type selection before title input

- Improve main menu navigation
  - Add main menu with action buttons for /start command
  - Create MainMenuButtons with New Listing, My Listings, My Bids, Settings, Help
  - Add back button to My Listings screen for better navigation
  - Implement proper dialogue state management between screens

- Refactor callback handling for type safety
  - Convert string literal matching to enum-based callback handling
  - Use try_from() pattern for all keyboard button callbacks
  - Ensure compile-time safety and exhaustive matching
  - Apply pattern to listing type, slots, duration, and start time callbacks

- Eliminate code duplication
  - Extract reusable main menu functions (enter_main_menu, get_main_menu_message)
  - Centralize main menu logic and message content
  - Update all main menu transitions to use shared functions

- Technical improvements
  - Add proper error handling for invalid callback data
  - Maintain backward compatibility with existing flows
  - Follow established patterns for keyboard button definitions
  - Ensure all changes compile without errors
2025-08-30 05:45:49 +00:00
Dylan Knutson
9ef36b760e feat: Add high-level integration testing framework
- Created trait abstractions for dependency injection with mockall support
- Added comprehensive integration tests focusing on:
  * Database operations and persistence
  * Complete user workflow scenarios
  * Dialogue state transitions and validation
  * Multi-user scenarios and data integrity
- Removed trivial unit tests, kept only meaningful business logic tests
- Added UserRepository, ListingRepository, and BotMessenger traits
- Integration tests verify end-to-end workflows with real database
- Total: 30 focused tests (4 unit + 8 integration + 18 validation tests)

Key integration test scenarios:
- Complete listing creation workflow with database persistence
- Listing update workflows with state validation
- Multi-user independent operations
- Error handling that preserves data integrity
- Business rule enforcement (e.g., no timing changes on live listings)

Framework ready for advanced mock-based testing when needed.
2025-08-30 04:41:07 +00:00
Dylan Knutson
5fe4a52c2b refactor: Split new_listing module into logical submodules
- Organized 810-line monolithic file into 10 focused modules
- Main mod.rs reduced from 810 to 27 lines (96.7% reduction)
- Clear separation of concerns with logical hierarchy:

Module Structure:
├── mod.rs (27 lines) - Module coordinator and exports
├── handlers.rs (362 lines) - Main teloxide handler functions
├── callbacks.rs (205 lines) - Callback query processing
├── validations.rs (145 lines) - Input validation logic
├── ui.rs (102 lines) - Display and summary functions
├── types.rs (82 lines) - Data structures and states
├── field_processing.rs (76 lines) - Core field update logic
├── messages.rs (73 lines) - Centralized message constants
├── handler_factory.rs (60 lines) - Teloxide handler tree
└── keyboard.rs (55 lines) - Button and keyboard definitions

Benefits:
- Single responsibility principle enforced
- Easy navigation and maintenance
- Reduced coupling between components
- Enhanced testability
- All 112 tests still passing
2025-08-30 04:15:27 +00:00
Dylan Knutson
24819633f5 refactor: Aggressive refactoring of new_listing module
- Reduced code size by 26.7% (1083→810 lines)
- Eliminated ALL duplication patterns:
  * Consolidated 12 individual handlers into 2 unified handlers
  * Centralized all step messages and success messages into constants
  * Removed repetitive pattern matching and state transitions
- Major architectural improvements:
  * Refactored ListingFields enum to use struct-based variants
  * Enhanced type safety with dedicated field structs
  * Simplified field access patterns throughout codebase
- Updated database layer for new enum structure
- Maintained full teloxide compatibility and functionality
- All 112 tests still passing
2025-08-30 03:07:31 +00:00
Dylan Knutson
143bf3ce41 refactor: Update listing confirmation flow and summary display
- Consolidate Create and Save button handling in confirmation flow
- Add unsaved changes indicator to listing summary
- Improve edit flow by using enter_edit_listing_draft function
- Update confirmation button logic and user feedback messages
- Clean up dialogue state management in confirmation handlers
2025-08-30 01:29:29 +00:00
Dylan Knutson
8fb51d12a7 feat: Add natural language duration parsing to validate_duration
- Support parsing strings like '1 hour', '7 days' in addition to plain numbers
- Maintain backwards compatibility with numeric input (e.g., '24')
- Support units: hour(s), hr(s), day(s)
- Add comprehensive test suite using rstest
- Enforce 1-720 hour limit (1 hour to 30 days)
- Provide clear error messages for invalid input
2025-08-30 00:17:24 +00:00
Dylan Knutson
374abf8c42 major db refactors 2025-08-29 23:25:12 +00:00
61 changed files with 6381 additions and 2797 deletions

211
Cargo.lock generated
View File

@@ -121,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
dependencies = [
"include_dir",
"itertools",
"itertools 0.10.5",
"proc-macro-error2",
"proc-macro2",
"quote",
@@ -134,6 +134,17 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "atoi"
version = "2.0.0"
@@ -518,6 +529,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "dptree"
version = "0.5.1"
@@ -543,6 +560,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.3"
@@ -667,6 +693,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "funty"
version = "2.0.0"
@@ -824,6 +856,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -942,6 +993,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -953,6 +1005,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
@@ -988,9 +1056,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1213,6 +1283,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -1376,6 +1455,32 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mockall"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1584,26 +1689,42 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pawctioneer-bot"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"chrono",
"dotenvy",
"dptree",
"env_logger",
"futures",
"itertools 0.14.0",
"lazy_static",
"log",
"mockall",
"num",
"paste",
"regex",
"reqwest",
"rstest",
"rust_decimal",
"seq-macro",
"serde",
"sqlx",
"teloxide",
"teloxide-core",
"thiserror",
"tokio",
"uuid",
]
[[package]]
@@ -1719,6 +1840,32 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
@@ -1928,16 +2075,20 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -2225,6 +2376,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "seq-macro"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
[[package]]
name = "serde"
version = "1.0.219"
@@ -2679,6 +2836,27 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "take_mut"
version = "0.2.2"
@@ -2780,6 +2958,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "2.0.16"
@@ -2896,6 +3080,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.17"
@@ -3097,9 +3291,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.18.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"getrandom 0.3.3",
"js-sys",
@@ -3311,6 +3505,17 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0.219" }
teloxide = { version = "0.17.0", features = ["macros", "ctrlc_handler"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
sqlx = { version = "0.8.6", features = [
@@ -14,17 +15,26 @@ sqlx = { version = "0.8.6", features = [
"rust_decimal",
] }
rust_decimal = { version = "1.33" }
chrono = { version = "0.4" }
chrono = { version = "0.4", features = ["serde"] }
log = "0.4"
env_logger = "0.11.8"
anyhow = "1.0"
dotenvy = "0.15"
lazy_static = "1.4"
serde = "1.0.219"
futures = "0.3.31"
thiserror = "2.0.16"
teloxide-core = "0.13.0"
num = "0.4.3"
itertools = "0.14.0"
async-trait = "0.1"
regex = "1.11.2"
paste = "1.0"
dptree = "0.5.1"
seq-macro = "0.3.6"
base64 = "0.22.1"
mockall = "0.13.1"
reqwest = "0.12.23"
uuid = "1.18.1"
[dev-dependencies]
rstest = "0.26.1"

View File

@@ -9,12 +9,16 @@ CREATE TABLE users (
id INTEGER PRIMARY KEY,
telegram_id INTEGER UNIQUE NOT NULL,
username TEXT,
display_name TEXT,
first_name TEXT,
last_name TEXT,
is_banned INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE INDEX idx_users_telegram_id ON users(telegram_id);
CREATE INDEX idx_users_username ON users(username);
-- Main listing table (handles all listing types)
CREATE TABLE listings (
id INTEGER PRIMARY KEY,
@@ -22,6 +26,7 @@ CREATE TABLE listings (
listing_type TEXT NOT NULL, -- 'basic_auction', 'multi_slot_auction', 'fixed_price_listing', 'blind_auction'
title TEXT NOT NULL,
description TEXT,
currency_type TEXT NOT NULL, -- 'usd'
-- Pricing (stored as INTEGER cents for USD)
starting_bid INTEGER,
@@ -32,6 +37,7 @@ CREATE TABLE listings (
slots_available INTEGER DEFAULT 1,
-- Timing
is_active INTEGER DEFAULT 1,
starts_at TEXT DEFAULT CURRENT_TIMESTAMP,
ends_at TEXT NOT NULL,
anti_snipe_minutes INTEGER DEFAULT 5,
@@ -41,6 +47,10 @@ CREATE TABLE listings (
FOREIGN KEY (seller_id) REFERENCES users(id)
) STRICT;
CREATE INDEX idx_listings_seller_id ON listings(seller_id);
CREATE INDEX idx_listings_type ON listings(listing_type);
CREATE INDEX idx_listings_ends_at ON listings(ends_at);
-- Proxy bid strategies (NOT actual bids, but bidding strategies)
CREATE TABLE proxy_bids (
id INTEGER PRIMARY KEY,
@@ -57,6 +67,8 @@ CREATE TABLE proxy_bids (
UNIQUE(listing_id, buyer_id) -- One active proxy per user per listing
) STRICT;
CREATE INDEX idx_proxy_bids_listing_buyer ON proxy_bids(listing_id, buyer_id);
-- Actual bids that happened (events)
CREATE TABLE bids (
id INTEGER PRIMARY KEY,
@@ -81,6 +93,10 @@ CREATE TABLE bids (
FOREIGN KEY (proxy_bid_id) REFERENCES proxy_bids(id)
) STRICT;
CREATE INDEX idx_bids_listing_id ON bids(listing_id);
CREATE INDEX idx_bids_buyer_id ON bids(buyer_id);
CREATE INDEX idx_bids_amount ON bids(bid_amount);
-- Media attachments
CREATE TABLE listing_medias (
id INTEGER PRIMARY KEY,
@@ -93,6 +109,8 @@ CREATE TABLE listing_medias (
FOREIGN KEY (listing_id) REFERENCES listings(id)
) STRICT;
CREATE INDEX idx_listing_medias_listing_id ON listing_medias(listing_id);
-- User preferences
CREATE TABLE user_settings (
user_id INTEGER PRIMARY KEY,
@@ -104,12 +122,19 @@ CREATE TABLE user_settings (
FOREIGN KEY (user_id) REFERENCES users(id)
) STRICT;
-- Create indexes for better performance
CREATE INDEX idx_listings_seller_id ON listings(seller_id);
CREATE INDEX idx_listings_type ON listings(listing_type);
CREATE INDEX idx_listings_ends_at ON listings(ends_at);
CREATE INDEX idx_bids_listing_id ON bids(listing_id);
CREATE INDEX idx_bids_buyer_id ON bids(buyer_id);
CREATE INDEX idx_bids_amount ON bids(bid_amount);
CREATE INDEX idx_proxy_bids_listing_buyer ON proxy_bids(listing_id, buyer_id);
CREATE INDEX idx_listing_medias_listing_id ON listing_medias(listing_id);
-- Message about a listing that were forwarded from the bot to a channel or user
CREATE TABLE forwarded_listings (
id INTEGER PRIMARY KEY,
listing_id INTEGER NOT NULL,
telegram_message_id INTEGER NOT NULL,
telegram_chat_id INTEGER NOT NULL,
forwarding_user_id INTEGER NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (listing_id) REFERENCES listings(id),
FOREIGN KEY (forwarding_user_id) REFERENCES users(id)
) STRICT;
CREATE INDEX idx_forwarded_listings_listing_id ON forwarded_listings(listing_id);
CREATE INDEX idx_forwarded_listings_forwarding_user_id ON forwarded_listings(forwarding_user_id);

View File

@@ -0,0 +1,15 @@
-- Add constraint to prevent bids on inactive listings
-- This ensures atomicity at the database level
-- Create a trigger to prevent bids on inactive listings
-- This is more reliable than CHECK constraints in SQLite
CREATE TRIGGER prevent_bid_on_inactive_listing
BEFORE INSERT ON bids
FOR EACH ROW
WHEN (
SELECT COALESCE(is_active, 0) FROM listings WHERE id = NEW.listing_id
) != 1
BEGIN
SELECT RAISE(ABORT, 'Cannot place bid on inactive listing');
END;

View File

@@ -0,0 +1,336 @@
use crate::{
bidding::BiddingState,
commands::new_listing::validations::{validate_price, SetFieldError},
db::{bid::NewBid, listing::PersistedListing, user::PersistedUser, MoneyAmount},
message::MessageType,
App, BotError, BotResult, RootDialogue,
};
use anyhow::{anyhow, Context};
use chrono::Utc;
use itertools::Itertools;
use log::{error, info};
use teloxide::types::*;
pub async fn handle_awaiting_bid_amount_input(
app: App,
listing: PersistedListing,
dialogue: RootDialogue,
msg: Message,
) -> BotResult {
// parse the bid amount into a MoneyAmount
let text = msg
.text()
.ok_or(BotError::user_visible("Please enter a valid bid amount"))?;
let bid_amount = match validate_price(text) {
Ok(bid_amount) => bid_amount,
Err(SetFieldError::ValidationFailed(e)) => {
return Err(BotError::user_visible(e));
}
Err(other) => {
return Err(anyhow!("Error validating bid amount: {other:?}").into());
}
};
let bid_amount_str = format!("{}", bid_amount.with_type(listing.base.currency_type));
app.bot
.send_html_message(
format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"),
Some(InlineKeyboardMarkup::default().append_row([
InlineKeyboardButton::callback(
format!("Confirm bid amount: {bid_amount_str}"),
"confirm_bid",
),
InlineKeyboardButton::callback("Cancel", "cancel_bid"),
])),
)
.await?;
dialogue
.update(BiddingState::AwaitingConfirmBidAmount(
listing.persisted.id,
bid_amount,
))
.await
.context("failed to update dialogue")?;
Ok(())
}
pub async fn handle_awaiting_confirm_bid_amount_callback(
app: App,
buyer: PersistedUser,
listing: PersistedListing,
bid_amount: MoneyAmount,
dialogue: RootDialogue,
callback_query: CallbackQuery,
) -> BotResult {
let callback_data = callback_query
.data
.as_deref()
.ok_or(BotError::user_visible("Missing data in callback query"))?;
let bid_amount = match callback_data {
"confirm_bid" => bid_amount,
"cancel_bid" => {
dialogue.exit().await.context("failed to exit dialogue")?;
app.bot
.send_html_message("Bid cancelled".to_string(), None)
.await?;
return Ok(());
}
_ => {
return Err(BotError::user_visible(format!(
"Invalid response {callback_data}"
)))
}
};
if listing.base.ends_at < Utc::now() {
return app
.send_message(MessageType::BidInvalidListingExpired { listing, buyer })
.await;
}
let bid = NewBid::new_basic(listing.persisted.id, buyer.persisted.id, bid_amount);
let bid = app.daos.bid.insert_bid(&bid).await?;
dialogue.exit().await.context("failed to exit dialogue")?;
app.send_message(MessageType::BidHasBeenConfirmedForBuyer {
listing: listing.clone(),
bid: bid.clone(),
})
.await?;
let other_bidder_ids = app
.daos
.bid
.bidder_ids_for_listing(listing.persisted.id)
.await?
.into_iter()
.filter(|id| *id != buyer.persisted.id)
.unique();
for buyer in app.daos.user.where_in_ids(other_bidder_ids).await? {
info!("Sending outbid message to {buyer:?}");
app.send_message(MessageType::UserHasBeenOutbidForBuyer {
listing: listing.clone(),
buyer,
})
.await?;
}
if let Some(seller) = app.daos.user.find_by_id(listing.base.seller_id).await? {
app.send_message(MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
})
.await?;
} else {
error!("Seller not found for listing {listing:?}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{db::DAOs, message_sender::MockMessageSender, test_utils::*};
use chrono::{Duration, Utc};
use dptree::{
deps,
di::{DependencyMap, Injectable},
};
use mockall::predicate::function;
use std::str::FromStr;
struct Fixtures {
deps: DependencyMap,
seller: PersistedUser,
buyer: PersistedUser,
prev_buyer: PersistedUser,
listing: PersistedListing,
}
async fn set_up_fixtures() -> Fixtures {
let Deps { deps, .. } = create_deps().await;
let seller = with_test_user(&deps, |seller| {
seller.username = Some("seller".to_string());
seller.telegram_id = 123.into()
})
.await;
let buyer = with_test_user(&deps, |buyer| {
buyer.username = Some("buyer".to_string());
buyer.telegram_id = 456.into()
})
.await;
let prev_buyer = with_test_user(&deps, |buyer| {
buyer.username = Some("prev_buyer".to_string());
buyer.telegram_id = 789.into()
})
.await;
let listing = with_test_listing(&deps, &seller, |_| {}).await;
Fixtures {
deps,
seller,
buyer,
prev_buyer,
listing,
}
}
#[tokio::test]
async fn test_confirm_bid_with_expired_listing() {
let Fixtures {
deps,
buyer,
mut listing,
..
} = set_up_fixtures().await;
// listing has already expired
listing.base.ends_at = Utc::now() - Duration::days(1);
let listing = deps
.get::<DAOs>()
.listing
.update_listing(&listing)
.await
.unwrap();
let cb_query = create_tele_callback_query(
"confirm_bid",
create_tele_user(|user| user.id = buyer.telegram_id.into()),
);
let mut message_sender = MockMessageSender::new();
{
let l = listing.clone();
let b = buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidInvalidListingExpired { listing, buyer } => {
assert_eq!(listing, &l);
assert_eq!(buyer, &b);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
let deps = with_message_sender(deps, message_sender).await;
let mut deps = with_dialogue(deps, &buyer).await;
deps.insert_container(deps![
listing,
cb_query,
buyer,
MoneyAmount::from_str("100.00").unwrap()
]);
let ret = handle_awaiting_confirm_bid_amount_callback.inject(&deps)().await;
assert!(ret.is_ok(), "{ret:?}");
}
#[tokio::test]
async fn test_confirm_bid_amount() {
let Fixtures {
deps,
seller,
buyer,
prev_buyer,
listing,
} = set_up_fixtures().await;
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
prev_buyer.persisted.id,
MoneyAmount::from_str("50.00").unwrap(),
))
.await
.unwrap();
let mut message_sender = MockMessageSender::new();
{
let l = listing.clone();
let b = buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidHasBeenConfirmedForBuyer { listing, bid, .. } => {
assert_eq!(listing, &l);
assert_eq!(bid.buyer_id, b.persisted.id);
assert_eq!(bid.bid_amount.cents(), 10_000);
assert_eq!(bid.listing_id, l.persisted.id);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
{
let l = listing.clone();
let b = buyer.clone();
let s = seller.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
} => {
assert_eq!(listing, &l);
assert_eq!(buyer, &b);
assert_eq!(seller, &s);
assert_eq!(bid.buyer_id, b.persisted.id);
assert_eq!(bid.bid_amount.cents(), 10_000);
assert_eq!(bid.listing_id, l.persisted.id);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
{
let l = listing.clone();
let pb = prev_buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => {
assert_eq!(listing, &l);
assert_eq!(buyer, &pb);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
let deps = with_message_sender(deps, message_sender).await;
let cb_query = create_tele_callback_query(
"confirm_bid",
create_tele_user(|user| user.id = buyer.telegram_id.into()),
);
let mut deps = with_dialogue(deps, &buyer).await;
deps.insert_container(deps![
listing,
cb_query,
buyer,
MoneyAmount::from_str("100.00").unwrap()
]);
let ret = handle_awaiting_confirm_bid_amount_callback.inject(&deps)().await;
assert!(ret.is_ok(), "{ret:?}");
}
}

1
src/bidding/keyboards.rs Normal file
View File

@@ -0,0 +1 @@

189
src/bidding/mod.rs Normal file
View File

@@ -0,0 +1,189 @@
mod confirm_bid_amount_callback;
mod keyboards;
use crate::{
bidding::confirm_bid_amount_callback::{
handle_awaiting_bid_amount_input, handle_awaiting_confirm_bid_amount_callback,
},
case,
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
DbListingId, DbUserId, MoneyAmount, UserDAO,
},
dptree_utils::MapTwo,
handle_error::with_error_handler,
handler_utils::find_listing_by_id,
message_utils::user_name_or_link,
start_command_data::StartCommandData,
App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
};
use anyhow::Context;
use log::info;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use teloxide::{
dispatching::UpdateFilterExt,
types::{InlineKeyboardButton, InlineKeyboardMarkup, Update},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BiddingState {
AwaitingBidAmount(DbListingId),
AwaitingConfirmBidAmount(DbListingId, MoneyAmount),
}
impl From<BiddingState> for DialogueRootState {
fn from(state: BiddingState) -> Self {
DialogueRootState::Bidding(state)
}
}
pub fn bidding_handler() -> BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_place_bid_on_listing_start_command)
.branch(
dptree::entry()
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_place_bid_on_listing)),
),
)
.branch(
Update::filter_callback_query()
.filter_map(StartCommandData::get_from_callback_query)
.filter_map(StartCommandData::get_view_listing_bids_start_command)
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_view_listing_bids)),
)
.branch(
Update::filter_message()
.chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingBidAmount(listing_id)
)])
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_awaiting_bid_amount_input)),
)
.branch(
Update::filter_callback_query()
.chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount)
)])
.map2(|(listing_id, bid_amount): (DbListingId, MoneyAmount)| {
(listing_id, bid_amount)
})
.filter_map_async(async |listing_dao, listing_id| {
find_listing_by_id(listing_dao, listing_id).await
})
.endpoint(with_error_handler(
handle_awaiting_confirm_bid_amount_callback,
)),
)
}
async fn handle_place_bid_on_listing(
app: App,
user_dao: UserDAO,
user: PersistedUser,
listing: PersistedListing,
dialogue: RootDialogue,
) -> BotResult {
info!("Handling place bid on listing for listing {listing:?} for user {user:?}");
let seller = user_dao
.find_by_id(listing.base.seller_id)
.await?
.ok_or(BotError::UserVisibleError("Seller not found".to_string()))?;
let basic_auction_fields = match &listing.fields {
ListingFields::BasicAuction(fields) => fields,
_ => {
return Err(BotError::UserVisibleError(
"Unsupported listing type".to_string(),
))
}
};
dialogue
.update(BiddingState::AwaitingBidAmount(listing.persisted.id))
.await
.context("failed to update dialogue")?;
let mut response_lines = vec![];
response_lines.push(format!(
"Placing a bid <b>{title}</b>, ran by {seller}",
title = listing.base.title,
seller = user_name_or_link(&seller)
));
let currency_type = listing.base.currency_type;
response_lines.push(format!("You are bidding on this listing as: {user:?}"));
response_lines.push(format!(
"Minimum bid: {}",
basic_auction_fields.min_increment.with_type(currency_type)
));
let keyboard = InlineKeyboardMarkup::default()
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
app.bot
.send_html_message(response_lines.join("\n"), Some(keyboard))
.await?;
Ok(())
}
async fn handle_view_listing_bids(
app: App,
listing: PersistedListing,
user: PersistedUser,
) -> BotResult {
if listing.base.seller_id != user.persisted.id {
return Err(BotError::user_visible(
"You are not the seller of this listing",
));
}
let currency_type = listing.base.currency_type;
let bids = app.daos.bid.bids_for_listing(listing.persisted.id).await?;
let mut response_lines = vec![];
response_lines.push(format!(
"🔍 <b>Bids on <i>{title}</i></b>",
title = listing.base.title
));
let bidding_users = app
.daos
.user
.where_in_ids(bids.iter().map(|bid| bid.buyer_id))
.await?
.into_iter()
.map(|bid| (bid.persisted.id, bid))
.collect::<HashMap<DbUserId, PersistedUser>>();
if let Some(current_bid) = bids.first() {
let buyer = bidding_users
.get(&current_bid.buyer_id)
.ok_or(BotError::internal_str("Buyer not found"))?;
response_lines.push(format!(
"💰 Current highest bid: <b>{current_bid}</b> from {buyer_name}",
current_bid = current_bid.bid_amount.with_type(currency_type),
buyer_name = user_name_or_link(buyer)
));
} else {
response_lines.push("💰 No bids yet".to_string());
}
for bid in bids.iter() {
let bidder = bidding_users
.get(&bid.buyer_id)
.ok_or(BotError::internal_str("Bidder not found"))?;
response_lines.push(format!(
"💰 Bid: <b>{bid_amount}</b> from {buyer_name}",
bid_amount = bid.bid_amount.with_type(currency_type),
buyer_name = user_name_or_link(bidder)
));
}
Ok(())
}

296
src/bot_message_sender.rs Normal file
View File

@@ -0,0 +1,296 @@
use anyhow::Context;
use async_trait::async_trait;
use teloxide::{
payloads::{EditMessageTextSetters, SendMessageSetters},
prelude::Requester,
types::*,
Bot,
};
use crate::{
db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser},
message::MessageType,
message_sender::{BoxedMessageSender, MessageSender},
message_utils::user_name_or_link,
start_command_data::StartCommandData,
BotError, BotResult, MessageTarget,
};
pub struct BotMessageSender(Bot, Option<MessageTarget>);
impl BotMessageSender {
pub fn new(bot: Bot, message_target: Option<MessageTarget>) -> Self {
Self(bot, message_target)
}
}
#[async_trait]
impl MessageSender for BotMessageSender {
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender {
let clone = Self(self.0.clone(), Some(target));
Box::new(clone)
}
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
let target = self
.1
.clone()
.ok_or(BotError::internal_str("No message target"))?;
if let Some(message_id) = target.message_id {
log::info!("Editing message in chat: {target:?}");
let mut message = self
.0
.edit_message_text(target.chat_id, message_id, &text)
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await.context("failed to edit message")?;
} else {
log::info!("Sending message to chat: {target:?}");
let mut message = self
.0
.send_message(target.chat_id, &text)
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await.context("failed to send message")?;
}
Ok(())
}
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult {
self.0
.answer_inline_query(inline_query_id, results)
.await
.map(|_| ())
.map_err(|err| BotError::InternalError(err.into()))
}
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult {
self.0
.answer_callback_query(query_id)
.await
.map(|_| ())
.map_err(|err| BotError::InternalError(err.into()))
}
async fn get_me(&self) -> BotResult<Me> {
self.0
.get_me()
.await
.map_err(|err| BotError::InternalError(err.into()))
}
async fn send_message(&self, message: MessageType) -> BotResult {
match message {
MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => {
self.send_user_has_been_outbid(listing, buyer).await?;
}
MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
} => {
self.send_bid_has_been_placed(listing, buyer, seller, bid)
.await?;
}
MessageType::BidHasBeenConfirmedForBuyer { listing, bid } => {
self.send_bid_has_been_confirmed(listing, bid).await?;
}
MessageType::BidInvalidListingExpired { listing, buyer } => {
self.send_bid_invalid_listing_expired(listing, buyer)
.await?;
}
MessageType::UserHasWonListingForSeller {
listing,
buyer,
seller,
bid,
} => {
self.send_user_has_won_listing_for_seller(listing, buyer, seller, bid)
.await?;
}
MessageType::UserHasWonListingForBuyer {
listing,
buyer,
seller,
bid,
} => {
self.send_user_has_won_listing_for_buyer(listing, buyer, seller, bid)
.await?;
}
MessageType::UserHasLostListingForBuyer {
listing,
buyer,
seller,
} => {
self.send_user_has_lost_listing_for_buyer(listing, buyer, seller)
.await?;
}
MessageType::ListingExpiredWithNoWinnerForSeller { listing, seller } => {
self.send_listing_expired_with_no_winner_for_seller(listing, seller)
.await?;
}
}
Ok(())
}
}
impl BotMessageSender {
async fn send_user_has_been_outbid(
&self,
listing: PersistedListing,
buyer: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"You have been outbid for {title}",
title = listing.base.title
),
None,
)
.await
}
async fn send_bid_has_been_placed(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"A bid was placed on {title} for <b>{bid_amount}</b> by {buyer}",
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
title = listing.base.title,
buyer = user_name_or_link(&buyer),
),
Some(
InlineKeyboardMarkup::default().append_row([InlineKeyboardButton::callback(
"View Details",
StartCommandData::ViewListingDetailsAsBuyer(listing.persisted.id),
)]),
),
)
.await
}
async fn send_bid_has_been_confirmed(
&self,
listing: PersistedListing,
bid: PersistedBid,
) -> BotResult {
self.send_html_message(
format!(
"Bid placed for <b>{bid_amount}</b> on {title}",
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
title = listing.base.title,
),
None,
)
.await
}
async fn send_bid_invalid_listing_expired(
&self,
listing: PersistedListing,
buyer: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"Auction <b>{title}</b> already ended on <b>{ends_at}</b>",
title = listing.base.title,
ends_at = listing.base.ends_at.format("%Y-%m-%d %H:%M:%S")
),
None,
)
.await
}
async fn send_user_has_won_listing_for_seller(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"<b>{buyer}</b> has won {title} for <b>{bid_amount}</b>",
buyer = user_name_or_link(&buyer),
title = listing.base.title,
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
),
None,
)
.await
}
async fn send_user_has_won_listing_for_buyer(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"You have won {title} by {seller} for <b>{bid_amount}</b>",
title = listing.base.title,
seller = user_name_or_link(&seller),
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
),
None,
)
.await
}
async fn send_user_has_lost_listing_for_buyer(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"Auction {title} by {seller} has ended, and you were not the winner",
title = listing.base.title,
seller = user_name_or_link(&seller),
),
None,
)
.await
}
async fn send_listing_expired_with_no_winner_for_seller(
&self,
listing: PersistedListing,
seller: PersistedUser,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"Auction {title} has ended with no winner",
title = listing.base.title
),
None,
)
.await
}
}

24
src/bot_result.rs Normal file
View File

@@ -0,0 +1,24 @@
use anyhow::Error;
use teloxide::dispatching::DpHandlerDescription;
#[derive(thiserror::Error, Debug)]
pub enum BotError {
#[error("User visible error: {0}")]
UserVisibleError(String),
#[error(transparent)]
InternalError(#[from] anyhow::Error),
}
impl BotError {
pub fn user_visible(msg: impl Into<String>) -> Self {
Self::UserVisibleError(msg.into())
}
pub fn internal(msg: impl Into<Error>) -> Self {
Self::InternalError(msg.into())
}
pub fn internal_str(msg: impl Into<String>) -> Self {
Self::InternalError(anyhow::anyhow!(msg.into()))
}
}
pub type BotResult<T = ()> = Result<T, BotError>;
pub type BotHandler = dptree::Handler<'static, BotResult, DpHandlerDescription>;

View File

@@ -1,8 +1,7 @@
use teloxide::{prelude::*, types::Message, utils::command::BotCommands, Bot};
use crate::{App, BotResult, Command};
use teloxide::utils::command::BotCommands;
use crate::{Command, HandlerResult};
pub async fn handle_help(bot: Bot, msg: Message) -> HandlerResult {
pub async fn handle_help(app: App) -> BotResult {
let help_message = format!(
"📋 Available Commands:\n\n{}\n\n\
📧 Support: Contact @admin for help\n\
@@ -10,6 +9,6 @@ pub async fn handle_help(bot: Bot, msg: Message) -> HandlerResult {
Command::descriptions()
);
bot.send_message(msg.chat.id, help_message).await?;
app.bot.send_html_message(help_message, None).await?;
Ok(())
}

View File

@@ -3,9 +3,9 @@ pub mod my_bids;
pub mod my_listings;
pub mod new_listing;
pub mod settings;
pub mod start;
mod start;
pub use help::handle_help;
pub use my_bids::handle_my_bids;
pub use settings::handle_settings;
pub use start::handle_start;
pub use start::{enter_main_menu, handle_main_menu_callback, handle_start};

View File

@@ -1,16 +1,16 @@
use crate::{App, BotResult};
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::types::Message;
use crate::HandlerResult;
pub async fn handle_my_bids(bot: Bot, msg: Message) -> HandlerResult {
pub async fn handle_my_bids(app: App, msg: Message) -> BotResult {
let response = "🎯 My Bids (Coming Soon)\n\n\
Here you'll be able to view:\n\
• Your active bids\n\
• Bid status (winning/outbid)\n\
• Proxy bid settings\n\
• Auction end times\n\n\
Feature in development! 🏗️";
Feature in development! 🏗️"
.to_string();
info!(
"User {} ({}) checked their bids",
@@ -18,6 +18,6 @@ pub async fn handle_my_bids(bot: Bot, msg: Message) -> HandlerResult {
msg.chat.id
);
bot.send_message(msg.chat.id, response).await?;
app.bot.send_html_message(response, None).await?;
Ok(())
}

View File

@@ -1,268 +0,0 @@
use crate::{
case,
db::{Listing, ListingDAO, ListingId, User, UserDAO},
keyboard_buttons,
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{InlineKeyboardButton, Message},
Bot,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MyListingsState {
ViewingListings,
ManagingListing(ListingId),
EditingListing(ListingId),
}
impl From<MyListingsState> for DialogueRootState {
fn from(state: MyListingsState) -> Self {
DialogueRootState::MyListings(state)
}
}
keyboard_buttons! {
enum ManageListingButtons {
[
Edit("✏️ Edit", "manage_listing_edit"),
Delete("🗑️ Delete", "manage_listing_delete"),
],
[
Back("⬅️ Back", "manage_listing_back"),
]
}
}
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings].endpoint(handle_my_listings_command_input),
),
)
.branch(
Update::filter_callback_query()
.branch(
// Callback when user taps a listing ID button to manage that listing
case![DialogueRootState::MyListings(
MyListingsState::ViewingListings
)]
.endpoint(handle_viewing_listings_callback),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.endpoint(handle_managing_listing_callback),
),
)
}
async fn handle_my_listings_command_input(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
msg: Message,
) -> HandlerResult {
let from = msg.from.unwrap();
show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?;
Ok(())
}
async fn show_listings_for_user(
db_pool: SqlitePool,
dialogue: RootDialogue,
bot: Bot,
user: teloxide::types::UserId,
target: impl Into<MessageTarget>,
) -> HandlerResult {
// If we reach here, show the listings menu
let user = match UserDAO::find_by_telegram_id(&db_pool, user).await? {
Some(user) => user,
None => {
send_message(
&bot,
target,
"You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
let listings = ListingDAO::find_by_seller(&db_pool, user.id).await?;
if listings.is_empty() {
send_message(
&bot,
target,
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet.\n\
Use /newlisting to create your first listing!",
None,
)
.await?;
return Ok(());
}
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
listing.base.title.to_string(),
listing.base.id.to_string(),
)]);
}
let mut response = format!(
"📋 <b>My Listings</b>\n\n\
You have {}.\n\n",
pluralize_with_count(listings.len(), "listing", "listings")
);
// Add each listing with its ID and title
for listing in &listings {
response.push_str(&format!(
"• <b>ID {}:</b> {}\n",
listing.base.id, listing.base.title
));
}
response.push_str("\nTap a listing ID below to view details:");
send_message(&bot, target, response, Some(keyboard)).await?;
Ok(())
}
async fn handle_viewing_listings_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
let listing_id = ListingId::new(data.parse::<i64>()?);
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await?;
show_listing_details(&bot, listing, target).await?;
Ok(())
}
async fn show_listing_details(
bot: &Bot,
listing: Listing,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let response = format!(
"🔍 <b>Viewing Listing Details</b>\n\n\
<b>Title:</b> {}\n\
<b>Description:</b> {}\n\
<b>ID:</b> {}",
listing.base.title,
listing
.base
.description
.as_deref()
.unwrap_or("No description"),
listing.base.id
);
send_message(
bot,
target,
response,
Some(ManageListingButtons::to_keyboard()),
)
.await?;
Ok(())
}
async fn handle_managing_listing_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
listing_id: ListingId,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
let button = ManageListingButtons::try_from(data.as_str())
.map_err(|_| anyhow::anyhow!("Invalid ManageListingButtons callback data: {}", data))?;
match button {
ManageListingButtons::Edit => {
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
dialogue
.update(MyListingsState::EditingListing(listing.base.id))
.await?;
}
ManageListingButtons::Delete => {
ListingDAO::delete_listing(&db_pool, listing_id).await?;
send_message(&bot, target, "Listing deleted.", None).await?;
}
ManageListingButtons::Back => {
dialogue.update(MyListingsState::ViewingListings).await?;
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?;
}
}
Ok(())
}
async fn get_user_and_listing(
db_pool: &SqlitePool,
bot: &Bot,
user_id: teloxide::types::UserId,
listing_id: ListingId,
target: impl Into<MessageTarget>,
) -> HandlerResult<(User, Listing)> {
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
Some(user) => user,
None => {
send_message(
bot,
target,
"❌ You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
Some(listing) => listing,
None => {
send_message(bot, target, "❌ Listing not found.", None).await?;
return Err(anyhow::anyhow!("Listing not found"));
}
};
if listing.base.seller_id != user.id {
send_message(
bot,
target,
"❌ You can only manage your own auctions.",
None,
)
.await?;
return Err(anyhow::anyhow!("User does not own listing"));
}
Ok((user, listing))
}

View File

@@ -0,0 +1,70 @@
use crate::{
db::{listing::PersistedListing, DbListingId},
keyboard_buttons,
};
use regex::Regex;
use teloxide::types::InlineKeyboardButton;
// keyboard_buttons! {
// pub enum MyListingsButtons {
// // SelectListing("Select Listing", "my_listings:", ListingDbId ),
// SelectListing("Select Listing", "my_listings:"),
// BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
// }
// }
pub enum MyListingsButtons {
SelectListing(DbListingId),
NewListing,
}
impl MyListingsButtons {
pub fn listing_into_button(listing: &PersistedListing) -> InlineKeyboardButton {
let text = format!(
"{} {} - {}",
listing.fields.listing_type().emoji_str(),
listing.fields.listing_type(),
listing.base.title,
);
InlineKeyboardButton::callback(text, Self::encode_listing_id(listing.persisted.id))
}
pub fn new_listing_into_button() -> InlineKeyboardButton {
InlineKeyboardButton::callback(" New Listing", "my_listings_new_listing")
}
fn encode_listing_id(listing_id: DbListingId) -> String {
format!("my_listings:{listing_id}")
}
fn decode_listing_id(value: &str) -> Option<DbListingId> {
let re = Regex::new(r"my_listings:(\d+)").ok()?;
let caps = re.captures(value)?;
let listing_id = caps.get(1)?.as_str().parse::<i64>().ok()?;
Some(DbListingId::new(listing_id))
}
}
impl TryFrom<&str> for MyListingsButtons {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if let Some(listing_id) = Self::decode_listing_id(value) {
return Ok(MyListingsButtons::SelectListing(listing_id));
}
match value {
"my_listings_new_listing" => Ok(MyListingsButtons::NewListing),
_ => anyhow::bail!("Unknown MyListingsButtons: {value}"),
}
}
}
keyboard_buttons! {
pub enum ManageListingButtons {
[
PreviewMessage("👀 Preview", "manage_listing_preview"),
ForwardListing("↪️ Forward", "manage_listing_forward"),
],
[Edit("✏️ Edit", "manage_listing_edit"),],
[Delete("🗑️ Delete", "manage_listing_delete"),],
[Back("⬅️ Back", "manage_listing_back"),]
}
}

View File

@@ -0,0 +1,462 @@
mod keyboard;
use std::ops::Deref;
use crate::{
case,
commands::{
enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{
enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons,
messages::steps_for_listing_type, ListingDraft,
},
},
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
DAOs, DbListingId, ListingType,
},
handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update},
message_utils::{extract_callback_data, pluralize_with_count, user_name_or_link},
start_command_data::StartCommandData,
App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
};
use anyhow::Context;
use log::info;
use serde::{Deserialize, Serialize};
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, ParseMode, User,
},
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MyListingsState {
ViewingListings,
ManagingListing(DbListingId),
}
impl From<MyListingsState> for DialogueRootState {
fn from(state: MyListingsState) -> Self {
DialogueRootState::MyListings(state)
}
}
pub fn my_listings_inline_handler() -> Handler<'static, BotResult, DpHandlerDescription> {
Update::filter_inline_query()
.filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing)
}
pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message()
.filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_view_listing_details_as_buyer_start_command)
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_view_listing_details)),
)
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_update)
.endpoint(with_error_handler(handle_my_listings_command_input)),
),
)
.branch(
Update::filter_callback_query()
.branch(
// Callback when user taps a listing ID button to manage that listing
case![DialogueRootState::MyListings(
MyListingsState::ViewingListings
)]
.endpoint(with_error_handler(handle_viewing_listings_callback)),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.endpoint(with_error_handler(handle_managing_listing_callback)),
),
)
}
async fn handle_view_listing_details(app: App, listing: PersistedListing) -> BotResult {
send_listing_details_message(app, listing, None).await?;
Ok(())
}
async fn inline_query_extract_forward_listing(
app: App,
inline_query: InlineQuery,
) -> Option<PersistedListing> {
let query = &inline_query.query;
info!("Try to extract forward listing from query: {query}");
if !query.starts_with("forward_listing:") {
return None;
}
let listing_id_str = query.split(":").nth(1)?;
let listing_id = DbListingId::new(listing_id_str.parse::<i64>().ok()?);
let listing = app
.daos
.listing
.find_by_id(listing_id)
.await
.unwrap_or(None)?;
Some(listing)
}
async fn handle_forward_listing(
app: App,
inline_query: InlineQuery,
listing: PersistedListing,
) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}");
// Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default()
.append_row([
InlineKeyboardButton::url(
"💰 Place Bid",
app.url_for_start_command(StartCommandData::PlaceBidOnListing(
listing.persisted.id,
)),
),
InlineKeyboardButton::callback(
"👀 Watch",
format!("inline_watch:{}", listing.persisted.id),
),
])
.append_row([InlineKeyboardButton::url(
"🔗 View Full Details",
app.url_for_start_command(StartCommandData::ViewListingDetailsAsBuyer(
listing.persisted.id,
)),
)]);
// Get the current price based on listing type
let current_price = get_listing_current_price(&listing);
let seller = app
.daos
.user
.find_by_id(listing.base.seller_id)
.await?
.ok_or(BotError::internal_str("Seller not found"))?;
// Create a more detailed message content for the shared listing
let message_content = format!(
"🎯 <b>{title}</b> by {seller}\n\n\
📝 {description}\n\n\
💰 <b>Current Price:</b> ${current_price}\n\
⏰ <b>Ends:</b> {ends_at}\n\n\
<i>Use the buttons below to interact! ⬇️</i>",
title = listing.base.title,
seller = user_name_or_link(&seller),
description = listing
.base
.description
.as_deref()
.unwrap_or("No description"),
ends_at = listing.base.ends_at.format("%b %d, %Y at %H:%M UTC")
);
app.bot
.answer_inline_query(
inline_query.id,
vec![InlineQueryResult::Article(
InlineQueryResultArticle::new(
listing.persisted.id.to_string(),
format!("💰 {} - ${}", listing.base.title, current_price),
InputMessageContent::Text(
InputMessageContentText::new(message_content).parse_mode(ParseMode::Html),
),
)
.description(&listing.base.title)
.reply_markup(keyboard), // Add the inline keyboard here!
)],
)
.await
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
Ok(())
}
/// Helper function to get the current price of a listing based on its type
fn get_listing_current_price(listing: &PersistedListing) -> String {
use crate::db::listing::ListingFields;
match &listing.fields {
ListingFields::BasicAuction(fields) => {
// For basic auctions, show starting bid (in a real app, you'd show current highest bid)
format!("{}", fields.starting_bid)
}
ListingFields::MultiSlotAuction(fields) => {
// For multi-slot auctions, show starting bid
format!("{}", fields.starting_bid)
}
ListingFields::FixedPriceListing(fields) => {
// For fixed price listings, show the fixed price
format!("{}", fields.buy_now_price)
}
ListingFields::BlindAuction(fields) => {
// For blind auctions, show starting bid
format!("{}", fields.starting_bid)
}
}
}
async fn handle_my_listings_command_input(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
) -> BotResult {
enter_my_listings(app, dialogue, user, None).await?;
Ok(())
}
pub async fn enter_my_listings(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
flash: Option<String>,
) -> BotResult {
// Transition to ViewingListings state
dialogue
.update(MyListingsState::ViewingListings)
.await
.context("failed to update dialogue")?;
let listings = app.daos.listing.find_by_seller(user.persisted.id).await?;
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
keyboard = keyboard.append_row(vec![MyListingsButtons::listing_into_button(listing)]);
}
keyboard = keyboard.append_row(vec![
MyListingsButtons::new_listing_into_button(),
NavKeyboardButtons::Back.to_button(),
]);
if listings.is_empty() {
app.bot
.send_html_message(
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet."
.to_string(),
Some(keyboard),
)
.await?;
return Ok(());
}
let mut response = format!(
"📋 <b>My Listings</b>\n\n\
You have {}.\n\n\
Select a listing to view details",
pluralize_with_count(listings.len(), "listing", "listings")
);
if let Some(flash) = flash {
response = format!("{flash}\n\n{response}");
}
app.bot.send_html_message(response, Some(keyboard)).await?;
Ok(())
}
async fn handle_viewing_listings_callback(
app: App,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
) -> BotResult {
let data = extract_callback_data(app.bot.deref(), callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(app, dialogue).await;
}
// Check if it's the back to menu button
let button = MyListingsButtons::try_from(data.as_str())?;
match button {
MyListingsButtons::SelectListing(listing_id) => {
let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
enter_show_listing_details(app, dialogue, listing).await?;
}
MyListingsButtons::NewListing => {
enter_select_new_listing_type(app, dialogue).await?;
}
}
Ok(())
}
async fn enter_show_listing_details(
app: App,
dialogue: RootDialogue,
listing: PersistedListing,
) -> BotResult {
let listing_id = listing.persisted.id;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await
.context("failed to update dialogue")?;
let keyboard = InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
InlineKeyboardButton::switch_inline_query(
ManageListingButtons::ForwardListing.title(),
format!(
"forward_listing:{listing_id}:{nonce}",
nonce = uuid::Uuid::new_v4()
),
),
])
.append_row([
ManageListingButtons::Edit.to_button(),
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]);
send_listing_details_message(app, listing, Some(keyboard)).await?;
Ok(())
}
async fn send_listing_details_message(
app: App,
listing: PersistedListing,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
let mut response_lines = vec![format!("🔍 <b>{listing_type} Details</b>")];
response_lines.push("".to_string());
let draft = ListingDraft::from_persisted(listing);
for step in steps_for_listing_type(listing_type) {
let field_value = match (step.get_field_value)(&draft) {
Ok(value) => value.unwrap_or_else(|| "(none)".to_string()),
Err(_) => continue,
};
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
}
app.bot
.send_html_message(response_lines.join("\n"), keyboard)
.await?;
Ok(())
}
async fn handle_managing_listing_callback(
app: App,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
listing_id: DbListingId,
) -> BotResult {
let from = callback_query.from.clone();
let data = extract_callback_data(&app.bot, callback_query).await?;
match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => {
let listing = app
.daos
.listing
.find_by_id(listing_id)
.await?
.ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(app, listing, from).await?;
}
ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(app, draft, dialogue, None).await?;
}
ManageListingButtons::Delete => {
app.daos.listing.delete_listing(listing_id).await?;
enter_my_listings(app, dialogue, user, Some("Listing deleted.".to_string())).await?;
}
ManageListingButtons::Back => {
enter_my_listings(app, dialogue, user, None).await?;
}
}
Ok(())
}
fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();
match listing.fields {
ListingFields::FixedPriceListing(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Buy",
"fixed_price_listing_buy",
)]);
}
ListingFields::BasicAuction(_) => {
keyboard = keyboard.append_row([
InlineKeyboardButton::callback("Submit Bid", "basic_auction_bid"),
InlineKeyboardButton::callback("Buy It Now", "basic_auction_buy_it_now"),
]);
}
ListingFields::MultiSlotAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"multi_slot_auction_submit_bid",
)]);
}
ListingFields::BlindAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"blind_auction_submit_bid",
)]);
}
};
keyboard
}
async fn send_preview_listing_message(
app: App,
listing: PersistedListing,
from: User,
) -> BotResult {
let mut response_lines = vec![];
response_lines.push(format!("<b>{}</b>", &listing.base.title));
if let Some(description) = &listing.base.description {
response_lines.push(description.to_owned());
}
app.bot
.with_target(from.into())
.send_html_message(
response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)),
)
.await?;
Ok(())
}
async fn get_listing_for_user(
daos: &DAOs,
user: PersistedUser,
listing_id: DbListingId,
) -> BotResult<PersistedListing> {
let listing = match daos.listing.find_by_id(listing_id).await? {
Some(listing) => listing,
None => {
return Err(BotError::UserVisibleError("❌ Listing not found.".into()));
}
};
if listing.base.seller_id != user.persisted.id {
return Err(BotError::UserVisibleError(
"❌ You can only manage your own auctions.".into(),
));
}
Ok(listing)
}

View File

@@ -0,0 +1,300 @@
//! Callback handling for the new listing wizard
//!
//! This module handles all callback query processing for buttons
//! in the new listing creation and editing workflows.
use std::str::FromStr;
use crate::{
commands::{
my_listings::enter_my_listings,
new_listing::{
enter_select_new_listing_type,
field_processing::{transition_to_field, update_field_on_draft},
keyboard::*,
messages::*,
types::{ListingDraft, ListingField},
ui::enter_confirm_save_listing,
},
},
db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
message_utils::*,
App, BotResult, RootDialogue,
};
use log::{error, info};
use teloxide::types::CallbackQuery;
/// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User selected listing type: {data:?}");
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_my_listings(app, dialogue, user, None).await;
}
// Parse the listing type from callback data
let (listing_type, type_name) = match ListingTypeKeyboardButtons::try_from(data.as_str())? {
ListingTypeKeyboardButtons::FixedPrice => {
(ListingType::FixedPriceListing, "Fixed Price Listing")
}
ListingTypeKeyboardButtons::BasicAuction => (ListingType::BasicAuction, "Basic Auction"),
ListingTypeKeyboardButtons::BlindAuction => (ListingType::BlindAuction, "Blind Auction"),
ListingTypeKeyboardButtons::MultiSlot => {
(ListingType::MultiSlotAuction, "Multi-Slot Auction")
}
};
// Create draft with selected listing type
let draft = ListingDraft::new_for_seller_with_type(user.persisted.id, listing_type);
// Transition to first field (Title)
transition_to_field(dialogue, ListingField::Title, draft).await?;
let response = format!(
"✅ <b>{} selected!</b>\n\n{}",
type_name,
get_step_message(ListingField::Title, listing_type)
);
app.bot
.send_html_message(response, get_keyboard_for_field(ListingField::Title))
.await?;
Ok(())
}
/// Handle callbacks during the field input phase
pub async fn handle_awaiting_draft_field_callback(
app: App,
dialogue: RootDialogue,
field: ListingField,
draft: ListingDraft,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User selected callback: {data:?}");
if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) {
match button {
NavKeyboardButtons::Back => {
return enter_select_new_listing_type(app, dialogue).await;
}
NavKeyboardButtons::Skip => {
return handle_skip_field(app, dialogue, field, draft).await;
}
NavKeyboardButtons::Cancel => {
return cancel_wizard(app, dialogue).await;
}
}
}
// Unified callback dispatch
match field {
ListingField::Slots => {
let button = SlotsKeyboardButtons::try_from(data.as_str())?;
handle_slots_callback(app, dialogue, draft, button).await
}
ListingField::StartTime => {
let button = StartTimeKeyboardButtons::try_from(data.as_str())?;
handle_start_time_callback(app, dialogue, draft, button).await
}
ListingField::EndTime => {
let button = DurationKeyboardButtons::try_from(data.as_str())?;
handle_duration_callback(app, dialogue, draft, button).await
}
ListingField::MinBidIncrement => {
let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?;
handle_starting_bid_amount_callback(app, dialogue, draft, button).await
}
ListingField::CurrencyType => {
let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?;
handle_currency_type_callback(app, dialogue, draft, button).await
}
_ => {
error!("Unknown callback data for field {field:?}: {data}");
Ok(())
}
}
}
async fn handle_skip_field(
app: App,
dialogue: RootDialogue,
current_field: ListingField,
draft: ListingDraft,
) -> BotResult {
let field_name = get_field_name(current_field, draft.listing_type());
let next_field = get_next_field(current_field, draft.listing_type());
let flash = format!("{field_name} skipped!");
if let Some(next_field) = next_field {
let response = format!(
"{}\n{}",
flash,
get_step_message(next_field, draft.listing_type())
);
transition_to_field(dialogue, next_field, draft).await?;
app.bot
.send_html_message(response, get_keyboard_for_field(next_field))
.await?;
} else {
enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await?;
}
Ok(())
}
/// Handle slots selection callback
async fn handle_slots_callback(
app: App,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: SlotsKeyboardButtons,
) -> BotResult {
let num_slots = match button {
SlotsKeyboardButtons::OneSlot => 1,
SlotsKeyboardButtons::TwoSlots => 2,
SlotsKeyboardButtons::FiveSlots => 5,
SlotsKeyboardButtons::TenSlots => 10,
};
update_field_on_draft(
ListingField::Slots,
&mut draft,
Some(num_slots.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating slots: {e:?}"))?;
let response = format!(
"✅ Available slots: <b>{num_slots}</b>\n\n{}",
get_step_message(ListingField::StartTime, draft.listing_type())
);
transition_to_field(dialogue, ListingField::StartTime, draft).await?;
app.bot
.send_html_message(response, get_keyboard_for_field(ListingField::StartTime))
.await?;
Ok(())
}
/// Handle start time selection callback
async fn handle_start_time_callback(
app: App,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: StartTimeKeyboardButtons,
) -> BotResult {
let start_time = match button {
StartTimeKeyboardButtons::Now => ListingDuration::zero(),
};
update_field_on_draft(
ListingField::StartTime,
&mut draft,
Some(start_time.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating start time: {e:?}"))?;
let response = format!(
"✅ Listing will start: <b>{}</b>\n\n{}",
start_time,
get_step_message(ListingField::EndTime, draft.listing_type())
);
transition_to_field(dialogue, ListingField::EndTime, draft).await?;
app.bot
.send_html_message(response, get_keyboard_for_field(ListingField::EndTime))
.await?;
Ok(())
}
/// Handle duration selection callback
async fn handle_duration_callback(
app: App,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: DurationKeyboardButtons,
) -> BotResult {
let duration = ListingDuration::days(match button {
DurationKeyboardButtons::OneDay => 1,
DurationKeyboardButtons::ThreeDays => 3,
DurationKeyboardButtons::SevenDays => 7,
DurationKeyboardButtons::FourteenDays => 14,
});
update_field_on_draft(
ListingField::EndTime,
&mut draft,
Some(duration.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?;
let flash = get_success_message(ListingField::EndTime, draft.listing_type());
enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await
}
async fn handle_starting_bid_amount_callback(
app: App,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: EditMinimumBidIncrementKeyboardButtons,
) -> BotResult {
let starting_bid_amount = MoneyAmount::from_str(match button {
EditMinimumBidIncrementKeyboardButtons::OneDollar => "1.00",
EditMinimumBidIncrementKeyboardButtons::FiveDollars => "5.00",
EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00",
})
.map_err(|e| anyhow::anyhow!("Error parsing starting bid amount: {e:?}"))?;
update_field_on_draft(
ListingField::StartingBidAmount,
&mut draft,
Some(starting_bid_amount.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?;
let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type());
enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await
}
async fn handle_currency_type_callback(
app: App,
dialogue: RootDialogue,
mut draft: ListingDraft,
button: CurrencyTypeKeyboardButtons,
) -> BotResult {
let currency_type = match button {
CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd,
CurrencyTypeKeyboardButtons::Cad => CurrencyType::Cad,
CurrencyTypeKeyboardButtons::Gbp => CurrencyType::Gbp,
CurrencyTypeKeyboardButtons::Eur => CurrencyType::Eur,
};
update_field_on_draft(
ListingField::CurrencyType,
&mut draft,
Some(currency_type.to_string().as_str()),
)
.map_err(|e| anyhow::anyhow!("Error updating currency type: {e:?}"))?;
let next_field = ListingField::StartingBidAmount;
let response = format!(
"✅ Listing will use currency: <b>{}</b>\n\n{}",
currency_type,
get_step_message(next_field, draft.listing_type())
);
transition_to_field(dialogue, next_field, draft).await?;
app.bot
.send_html_message(response, get_keyboard_for_field(next_field))
.await?;
Ok(())
}
/// Cancel the wizard and exit
pub async fn cancel_wizard(app: App, dialogue: RootDialogue) -> BotResult {
info!("User cancelled new listing wizard");
enter_select_new_listing_type(app, dialogue).await?;
Ok(())
}

View File

@@ -0,0 +1,39 @@
//! Field processing logic for the new listing wizard
//!
//! This module handles the core logic for processing and updating listing fields
//! during both initial creation and editing workflows.
use anyhow::Context;
use crate::commands::new_listing::messages::step_for_field;
use crate::commands::new_listing::{types::NewListingState, validations::*};
use crate::{
commands::new_listing::types::{ListingDraft, ListingField},
BotResult, RootDialogue,
};
/// Helper function to transition to next field
pub async fn transition_to_field(
dialogue: RootDialogue,
field: ListingField,
draft: ListingDraft,
) -> BotResult {
dialogue
.update(NewListingState::AwaitingDraftField { field, draft })
.await
.context("failed to update dialogue")?;
Ok(())
}
/// Process field input and update the draft
pub fn update_field_on_draft(
field: ListingField,
draft: &mut ListingDraft,
text: Option<&str>,
) -> Result<(), SetFieldError> {
let step = step_for_field(field, draft.listing_type())
.ok_or(SetFieldError::UnsupportedFieldForListingType)?;
(step.set_field_value)(draft, text.map(|s| s.trim().to_string()))?;
draft.has_changes = true;
Ok(())
}

View File

@@ -1,9 +1,14 @@
use super::*;
use crate::{case, Command, DialogueRootState, Handler};
use super::{callbacks::*, handlers::*, types::*};
use crate::{
dptree_utils::{identity, MapTwo},
handle_error::with_error_handler,
BotHandler, Command, DialogueRootState,
};
use dptree::case;
use teloxide::{dptree, prelude::*, types::Update};
// Create the dialogue handler tree for new listing wizard
pub fn new_listing_handler() -> Handler {
pub fn new_listing_handler() -> BotHandler {
dptree::entry()
.branch(
Update::filter_message()
@@ -11,46 +16,47 @@ pub fn new_listing_handler() -> Handler {
dptree::entry()
.filter_command::<Command>()
.chain(case![Command::NewListing])
.endpoint(handle_new_listing_command),
.endpoint(with_error_handler(handle_new_listing_command)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::AwaitingDraftField { field, draft }
)]
.endpoint(handle_awaiting_draft_field_input),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft }
)]
.endpoint(handle_editing_field_input),
dptree::entry()
.chain(case![DialogueRootState::NewListing(state)])
.branch(
case![NewListingState::AwaitingDraftField { field, draft }]
.map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_awaiting_draft_field_input)),
)
.branch(
case![NewListingState::EditingDraftField { field, draft }]
.map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_editing_field_input)),
),
),
)
.branch(
Update::filter_callback_query()
.chain(case![DialogueRootState::NewListing(state)])
.branch(
case![DialogueRootState::NewListing(
NewListingState::AwaitingDraftField { field, draft }
)]
.endpoint(handle_awaiting_draft_field_callback),
case![NewListingState::SelectingListingType]
.endpoint(with_error_handler(handle_selecting_listing_type_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::ViewingDraft(draft)
)]
.endpoint(handle_viewing_draft_callback),
case![NewListingState::AwaitingDraftField { field, draft }]
.map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraft(draft)
)]
.endpoint(handle_editing_draft_callback),
case![NewListingState::ViewingDraft(draft)]
.endpoint(with_error_handler(handle_viewing_draft_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft }
)]
.endpoint(handle_editing_draft_field_callback),
case![NewListingState::EditingDraft(draft)]
.endpoint(with_error_handler(handle_editing_draft_callback)),
)
.branch(
case![NewListingState::EditingDraftField { field, draft }]
.map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_editing_draft_field_callback)),
),
)
}

View File

@@ -0,0 +1,322 @@
//! Main handler functions for the new listing wizard
//!
//! This module contains the primary handler functions that process
//! user input and manage the listing creation workflow.
use crate::{
commands::{
my_listings::enter_my_listings,
new_listing::{
field_processing::{transition_to_field, update_field_on_draft},
keyboard::{
ConfirmationKeyboardButtons, DurationKeyboardButtons,
FieldSelectionKeyboardButtons, SlotsKeyboardButtons, StartTimeKeyboardButtons,
},
messages::{
get_edit_success_message, get_keyboard_for_field, get_listing_type_keyboard,
get_listing_type_selection_message, get_next_field, get_step_message,
get_success_message, step_for_field,
},
types::{ListingDraft, ListingField, NewListingState},
ui::{display_listing_summary, enter_confirm_save_listing},
validations::SetFieldError,
},
},
db::{
listing::{NewListing, PersistedListing},
user::PersistedUser,
ListingDAO,
},
message_utils::*,
App, BotError, BotResult, DialogueRootState, RootDialogue,
};
use anyhow::{anyhow, Context};
use log::info;
use teloxide::{prelude::*, types::*};
/// Handle the /newlisting command - starts the dialogue
pub(super) async fn handle_new_listing_command(app: App, dialogue: RootDialogue) -> BotResult {
enter_select_new_listing_type(app, dialogue).await?;
Ok(())
}
pub async fn enter_select_new_listing_type(app: App, dialogue: RootDialogue) -> BotResult {
// Initialize the dialogue to listing type selection state
dialogue
.update(NewListingState::SelectingListingType)
.await
.context("failed to update dialogue")?;
app.bot
.send_html_message(
get_listing_type_selection_message().to_string(),
Some(get_listing_type_keyboard()),
)
.await?;
Ok(())
}
/// Handle text input for any field during creation
pub async fn handle_awaiting_draft_field_input(
app: App,
dialogue: RootDialogue,
field: ListingField,
mut draft: ListingDraft,
msg: Message,
) -> BotResult {
info!("User entered input step: {field:?}");
// Process the field update
match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (),
Err(SetFieldError::ValidationFailed(e)) => {
return Err(BotError::user_visible(e));
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
// Handle final step or transition to next
if let Some(next_field) = get_next_field(field, draft.listing_type()) {
let response = format!(
"{}\n\n{}",
get_success_message(field, draft.listing_type()),
get_step_message(next_field, draft.listing_type())
);
transition_to_field(dialogue, next_field, draft).await?;
app.bot
.send_html_message(response, get_keyboard_for_field(next_field))
.await?;
} else {
// Final step - go to confirmation
enter_confirm_save_listing(app, dialogue, draft, None).await?;
}
Ok(())
}
/// Handle text input for field editing
pub async fn handle_editing_field_input(
app: App,
dialogue: RootDialogue,
field: ListingField,
mut draft: ListingDraft,
msg: Message,
) -> BotResult {
info!("User editing field {field:?}");
// Process the field update
match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (),
Err(SetFieldError::ValidationFailed(e)) => {
return Err(BotError::user_visible(e));
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
let flash = get_edit_success_message(field, draft.listing_type());
enter_edit_listing_draft(app, draft, dialogue, Some(flash)).await?;
Ok(())
}
/// Handle viewing draft confirmation callbacks
pub async fn handle_viewing_draft_callback(
app: App,
dialogue: RootDialogue,
draft: ListingDraft,
user: PersistedUser,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
match ConfirmationKeyboardButtons::try_from(data.as_str())? {
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
info!("User confirmed listing creation");
let success_message = save_listing(&app.daos.listing, draft).await?;
enter_my_listings(app, dialogue, user, Some(success_message)).await?;
}
ConfirmationKeyboardButtons::Cancel => {
info!("User cancelled listing update");
let response = "🗑️ <b>Changes Discarded</b>\n\n\
Your changes have been discarded and not saved."
.to_string();
app.bot.send_html_message(response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Discard => {
info!("User discarded listing creation");
let response = "🗑️ <b>Listing Discarded</b>\n\n\
Your listing has been discarded and not created.\n\
You can start a new listing anytime with /newlisting."
.to_string();
app.bot.send_html_message(response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Edit => {
info!("User chose to edit listing");
enter_edit_listing_draft(app, draft, dialogue, None).await?;
}
}
Ok(())
}
/// Handle editing draft field selection callbacks
pub async fn handle_editing_draft_callback(
app: App,
draft: ListingDraft,
dialogue: RootDialogue,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User in editing screen, showing field selection");
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())?;
if button == FieldSelectionKeyboardButtons::Done {
return enter_confirm_save_listing(app, dialogue, draft, None).await;
}
let field = match button {
FieldSelectionKeyboardButtons::Title => ListingField::Title,
FieldSelectionKeyboardButtons::Description => ListingField::Description,
FieldSelectionKeyboardButtons::Price => ListingField::Price,
FieldSelectionKeyboardButtons::Slots => ListingField::Slots,
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
FieldSelectionKeyboardButtons::Duration => ListingField::EndTime,
FieldSelectionKeyboardButtons::Done => {
return Err(anyhow::anyhow!("Done button should not be used here").into());
}
};
let value = get_current_field_value(&draft, field)?;
let keyboard = get_edit_keyboard_for_field(field);
dialogue
.update(DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft },
))
.await
.context("failed to update dialogue")?;
let response = format!("Editing {field:?}\n\nPrevious value: {value}");
app.bot.send_html_message(response, Some(keyboard)).await?;
Ok(())
}
/// Handle editing draft field callbacks (back button, etc.)
pub async fn handle_editing_draft_field_callback(
app: App,
dialogue: RootDialogue,
field: ListingField,
draft: ListingDraft,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User editing field: {field:?} -> {data:?}");
if data == "edit_back" {
enter_edit_listing_draft(app, draft, dialogue, None).await?;
return Ok(());
}
// This callback handler typically receives button presses, not text input
// For now, just redirect back to edit screen since callback data isn't suitable for validation
enter_edit_listing_draft(app, draft, dialogue, None).await?;
Ok(())
}
/// Enter the edit listing draft screen
pub async fn enter_edit_listing_draft(
app: App,
draft: ListingDraft,
dialogue: RootDialogue,
flash_message: Option<String>,
) -> BotResult {
display_listing_summary(
app,
&draft,
Some(FieldSelectionKeyboardButtons::to_keyboard()),
flash_message,
)
.await?;
dialogue
.update(NewListingState::EditingDraft(draft))
.await
.context("failed to update dialogue")?;
Ok(())
}
/// Save the listing to the database
async fn save_listing(listing_dao: &ListingDAO, draft: ListingDraft) -> BotResult<String> {
let (listing, success_message) = if let Some(fields) = draft.persisted {
let listing = listing_dao
.update_listing(&PersistedListing {
persisted: fields,
base: draft.base,
fields: draft.fields,
})
.await?;
(listing, "Listing updated!")
} else {
let listing = listing_dao
.insert_listing(&NewListing {
persisted: (),
base: draft.base,
fields: draft.fields,
})
.await?;
(listing, "Listing created!")
};
Ok(format!("{success_message}: {}", listing.base.title))
}
/// Get the current value of a field for display
fn get_current_field_value(
draft: &ListingDraft,
field: ListingField,
) -> Result<String, anyhow::Error> {
let step = step_for_field(field, draft.listing_type())
.ok_or_else(|| anyhow::anyhow!("Cannot get field value for field {field:?}"))?;
match (step.get_field_value)(draft) {
Ok(value) => Ok(value.unwrap_or_else(|| "(none)".to_string())),
Err(e) => Err(anyhow::anyhow!(
"Cannot get field value for field {field:?}: {e:?}"
)),
}
}
/// Get the edit keyboard for a field
fn get_edit_keyboard_for_field(field: ListingField) -> InlineKeyboardMarkup {
use crate::message_utils::create_single_button_keyboard;
let back_button = create_single_button_keyboard("🔙 Back", "edit_back");
match field {
ListingField::Description => {
let clear_button =
create_single_button_keyboard("🧹 Clear description", "edit_clear_description");
back_button.append_row(clear_button.inline_keyboard[0].clone())
}
ListingField::Slots => {
back_button.append_row(SlotsKeyboardButtons::to_keyboard().inline_keyboard[0].clone())
}
ListingField::StartTime => back_button
.append_row(StartTimeKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
ListingField::EndTime => back_button
.append_row(DurationKeyboardButtons::to_keyboard().inline_keyboard[0].clone()),
_ => back_button,
}
}

View File

@@ -1,5 +1,26 @@
use crate::keyboard_buttons;
keyboard_buttons! {
pub enum NavKeyboardButtons {
Back("⬅️ Back", "back"),
Skip("⏭️ Skip", "skip"),
Cancel("❌ Cancel", "cancel"),
}
}
keyboard_buttons! {
pub enum CurrencyTypeKeyboardButtons {
[
Usd("🇺🇸 USD", "currency_type_usd"),
Cad("🇨🇦 CAD", "currency_type_cad"),
],
[
Gbp("🇬🇧 GBP", "currency_type_gbp"),
Eur("🇪🇺 EUR", "currency_type_eur"),
],
}
}
keyboard_buttons! {
pub enum DurationKeyboardButtons {
OneDay("1 day", "duration_1_day"),
@@ -20,9 +41,11 @@ keyboard_buttons! {
keyboard_buttons! {
pub enum ConfirmationKeyboardButtons {
Save("✅ Save", "confirm_save"),
Create("✅ Create", "confirm_create"),
Edit("✏️ Edit", "confirm_edit"),
Discard("🗑️ Discard", "confirm_discard"),
Cancel("❌ Cancel", "confirm_cancel"),
}
}
@@ -42,7 +65,7 @@ keyboard_buttons! {
],
[
Done("✅ Done", "edit_done"),
]
],
}
}
@@ -51,3 +74,24 @@ keyboard_buttons! {
Now("Now", "start_time_now"),
}
}
keyboard_buttons! {
pub enum ListingTypeKeyboardButtons {
[
FixedPrice("🛍️ Fixed Price", "listing_type_fixed_price"),
BasicAuction("⏰ Basic Auction", "listing_type_basic_auction"),
],
[
BlindAuction("🎭 Blind Auction", "listing_type_blind_auction"),
MultiSlot("🎯 Multi-Slot Auction", "listing_type_multi_slot"),
]
}
}
keyboard_buttons! {
pub enum EditMinimumBidIncrementKeyboardButtons {
OneDollar("$1.00", "edit_minimum_bid_increment_1_00"),
FiveDollars("$5.00", "edit_minimum_bid_increment_5_00"),
TenDollars("$10.00", "edit_minimum_bid_increment_10_00"),
}
}

View File

@@ -0,0 +1,371 @@
//! Message constants and generation functions for the new listing wizard
//!
//! This module centralizes all user-facing messages to eliminate duplication
//! and provide a single source of truth for wizard text.
use crate::commands::new_listing::types::ListingField;
use crate::commands::new_listing::validations::*;
use crate::commands::new_listing::{keyboard::*, ListingDraft};
use crate::db::listing::ListingFields;
use crate::db::ListingType;
use crate::message_utils::format_datetime;
use teloxide::types::InlineKeyboardMarkup;
#[derive(Debug)]
pub enum GetFieldError {
UnsupportedListingType,
}
pub type GetFieldResult<T> = Result<Option<T>, GetFieldError>;
#[derive(Copy, Clone)]
pub struct ListingStepData {
pub field_type: ListingField,
pub field_name: &'static str,
pub description: &'static [&'static str],
pub get_field_value: fn(&ListingDraft) -> GetFieldResult<String>,
pub set_field_value: fn(&mut ListingDraft, Option<String>) -> Result<(), SetFieldError>,
}
macro_rules! get_field_mut {
($fields:expr, $($variant:ident { $field:ident }),+) => {
match &mut $fields {
$(
get_field_mut!(@field_name $variant fields) => &mut fields.$field,
)+
_ => return Err(SetFieldError::UnsupportedFieldForListingType),
}
};
(@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) };
(@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) };
(@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) };
(@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) };
}
macro_rules! get_field {
($fields:expr, $($variant:ident { $field:ident }),+) => {
match &$fields {
$(
get_field!(@field_name $variant fields) => Ok(fields.$field),
)+
_ => Err(GetFieldError::UnsupportedListingType),
}
};
(@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) };
(@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) };
(@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) };
(@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) };
}
const TITLE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Title,
field_name: "Title",
description: &["Please enter a title for your listing (max 100 characters)"],
get_field_value: |draft| Ok(Some(draft.base.title.clone())),
set_field_value: |draft, value| {
draft.base.title = require_field(value).and_then(validate_title)?;
Ok(())
},
};
const DESCRIPTION_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Description,
field_name: "Description",
description: &["Please enter a description for your listing (optional)"],
get_field_value: |draft| Ok(draft.base.description.clone()),
set_field_value: |draft, value| {
draft.base.description = value.map(validate_description).transpose()?;
Ok(())
},
};
const CURRENCY_TYPE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::CurrencyType,
field_name: "Currency Type",
description: &["Please enter the currency type for your listing (optional)"],
get_field_value: |draft| Ok(Some(draft.base.currency_type.to_string())),
set_field_value: |draft, value| {
draft.base.currency_type = require_field(value).and_then(validate_currency_type)?;
Ok(())
},
};
const START_TIME_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::StartTime,
field_name: "Start Time",
description: &[
"When should your listing start?",
"• Click 'Now' to start immediately",
"• Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)",
"• Maximum delay: 168 hours (7 days)",
],
get_field_value: |draft| {
Ok(Some(format_datetime(draft.base.starts_at)))
},
set_field_value: |draft, value| {
draft.base.starts_at = require_field(value).and_then(validate_start_time)?;
Ok(())
},
};
const DURATION_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::EndTime,
field_name: "End Time",
description: &[
"When should your listing end?",
"• Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):",
],
get_field_value: |draft| Ok(Some(format_datetime(draft.base.ends_at))),
set_field_value: |draft, value| {
draft.base.ends_at =
draft.base.starts_at + require_field(value).and_then(validate_duration)?;
Ok(())
},
};
const PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Price,
field_name: "Price",
description: &[
"Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99)",
"• Price should be in USD",
],
get_field_value: |draft| {
let buy_now_price = get_field!(draft.fields, FixedPriceListingFields { buy_now_price })?;
Ok(Some(format!("${buy_now_price}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, FixedPriceListingFields { buy_now_price }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const SLOTS_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::Slots,
field_name: "Slots",
description: &[
"How many items are available for sale?",
"• Choose a common value below or enter a custom number (1-1000)",
],
get_field_value: |draft| {
let slots_available =
get_field!(draft.fields, FixedPriceListingFields { slots_available })?;
Ok(Some(format!("{slots_available} slots")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, FixedPriceListingFields { slots_available }) =
require_field(value).and_then(validate_slots)?;
Ok(())
},
};
const STARTING_BID_AMOUNT_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::StartingBidAmount,
field_name: "Starting Bid Amount",
description: &[
"Please enter the starting bid amount for your auction (e.g., 10.50, 25, 0.99):",
"• Starting bid should be in USD",
],
get_field_value: |draft| {
let starting_bid_amount = get_field!(draft.fields, BasicAuctionFields { starting_bid })?;
Ok(Some(format!("${starting_bid_amount}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { starting_bid }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const BUY_NOW_PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::BuyNowPrice,
field_name: "Buy Now Price",
description: &[
"Please enter the buy now price for your auction (e.g., 10.50, 25, 0.99):",
"• Buy now price should be in USD",
],
get_field_value: |draft| {
let buy_now_price = get_field!(draft.fields, BasicAuctionFields { buy_now_price })?;
Ok(buy_now_price.map(|price| format!("${price}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { buy_now_price }) =
value.map(validate_price).transpose()?;
Ok(())
},
};
const BID_INCREMENT_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::MinBidIncrement,
field_name: "Minimum Bid Increment",
description: &[
"Please enter the minimum bid increment for your auction (e.g., 10.50, 25, 0.99):",
"• Default: $1.00",
"• Minimum bid increment should be in USD",
],
get_field_value: |draft| {
let min_bid_increment = get_field!(draft.fields, BasicAuctionFields { min_increment })?;
Ok(Some(format!("${min_bid_increment}")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { min_increment }) =
require_field(value).and_then(validate_price)?;
Ok(())
},
};
const ANTI_SNIPE_MINUTES_LISTING_STEP_DATA: ListingStepData = ListingStepData {
field_type: ListingField::AntiSnipeMinutes,
field_name: "Anti-Snipe Minutes",
description: &[
"Please enter the anti-snipe minutes for your auction (e.g., 10, 15, 20):",
"• Default: 5 minutes",
"• Anti-snipe will extend the auction duration by the number of minutes entered",
"• Anti-snipe minutes should be in minutes",
],
get_field_value: |draft| {
let anti_snipe_minutes =
get_field!(draft.fields, BasicAuctionFields { anti_snipe_minutes })?;
Ok(anti_snipe_minutes.map(|minutes| format!("{minutes} minutes")))
},
set_field_value: |draft, value| {
*get_field_mut!(draft.fields, BasicAuctionFields { anti_snipe_minutes }) =
value.map(validate_anti_snipe_minutes).transpose()?;
Ok(())
},
};
const FIXED_PRICE_LISTING_STEPS: &[ListingStepData] = &[
TITLE_LISTING_STEP_DATA,
DESCRIPTION_LISTING_STEP_DATA,
CURRENCY_TYPE_LISTING_STEP_DATA,
PRICE_LISTING_STEP_DATA,
SLOTS_LISTING_STEP_DATA,
START_TIME_LISTING_STEP_DATA,
DURATION_LISTING_STEP_DATA,
];
const BASIC_AUCTION_STEPS: &[ListingStepData] = &[
TITLE_LISTING_STEP_DATA,
DESCRIPTION_LISTING_STEP_DATA,
CURRENCY_TYPE_LISTING_STEP_DATA,
STARTING_BID_AMOUNT_LISTING_STEP_DATA,
BUY_NOW_PRICE_LISTING_STEP_DATA,
BID_INCREMENT_LISTING_STEP_DATA,
ANTI_SNIPE_MINUTES_LISTING_STEP_DATA,
START_TIME_LISTING_STEP_DATA,
DURATION_LISTING_STEP_DATA,
];
const BLIND_AUCTION_STEPS: &[ListingStepData] = &[];
const MULTI_SLOT_AUCTION_STEPS: &[ListingStepData] = &[];
pub fn steps_for_listing_type(listing_type: ListingType) -> &'static [ListingStepData] {
match listing_type {
ListingType::FixedPriceListing => FIXED_PRICE_LISTING_STEPS,
ListingType::BasicAuction => BASIC_AUCTION_STEPS,
ListingType::BlindAuction => BLIND_AUCTION_STEPS,
ListingType::MultiSlotAuction => MULTI_SLOT_AUCTION_STEPS,
}
}
fn get_step_number(field: ListingField, listing_type: ListingType) -> Option<usize> {
let steps = steps_for_listing_type(listing_type);
steps.iter().position(|step| step.field_type == field)
}
fn get_total_steps(listing_type: ListingType) -> usize {
steps_for_listing_type(listing_type).len()
}
pub fn step_for_field(
field: ListingField,
listing_type: ListingType,
) -> Option<&'static ListingStepData> {
steps_for_listing_type(listing_type)
.iter()
.find(|step| step.field_type == field)
}
/// Get the next field in the wizard sequence
pub fn get_next_field(field: ListingField, listing_type: ListingType) -> Option<ListingField> {
let idx = get_step_number(field, listing_type)?;
let total_steps = get_total_steps(listing_type);
if idx + 1 >= total_steps {
return None;
}
Some(steps_for_listing_type(listing_type)[idx + 1].field_type)
}
/// Get the step instruction message for a field
pub fn get_step_message(field: ListingField, listing_type: ListingType) -> String {
let step = step_for_field(field, listing_type).unwrap();
let setp_number = get_step_number(field, listing_type).unwrap() + 1;
let total_steps = get_total_steps(listing_type);
let description = step.description.join("\n");
format!("<i>Step {setp_number} / {total_steps}</i>: {description}")
}
/// Get the success message for completing a field
pub fn get_success_message(field: ListingField, listing_type: ListingType) -> String {
let step = step_for_field(field, listing_type).unwrap();
let field_name = step.field_name;
format!("{field_name} saved!")
}
/// Get the success message for editing a field
pub fn get_edit_success_message(field: ListingField, listing_type: ListingType) -> String {
let step = step_for_field(field, listing_type).unwrap();
let field_name = step.field_name;
format!("{field_name} updated!")
}
pub fn get_field_name(field: ListingField, listing_type: ListingType) -> String {
let step = step_for_field(field, listing_type).unwrap();
step.field_name.to_string()
}
/// Get the appropriate keyboard for a field
pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarkup> {
match field {
ListingField::Title => Some(InlineKeyboardMarkup::new([[
NavKeyboardButtons::Back.to_button()
]])),
ListingField::Description => Some(InlineKeyboardMarkup::new([[
NavKeyboardButtons::Back.to_button(),
NavKeyboardButtons::Skip.to_button(),
]])),
ListingField::CurrencyType => Some(CurrencyTypeKeyboardButtons::to_keyboard()),
ListingField::Price => None,
ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()),
ListingField::StartTime => Some(StartTimeKeyboardButtons::to_keyboard()),
ListingField::EndTime => Some(DurationKeyboardButtons::to_keyboard()),
// TODO - Add keyboards for these fields
ListingField::StartingBidAmount => None,
ListingField::BuyNowPrice => Some(InlineKeyboardMarkup::new([[
NavKeyboardButtons::Skip.to_button()
]])),
ListingField::MinBidIncrement => Some(
EditMinimumBidIncrementKeyboardButtons::to_keyboard()
.append_row([NavKeyboardButtons::Skip.to_button()]),
),
ListingField::AntiSnipeMinutes => Some(InlineKeyboardMarkup::new([[
NavKeyboardButtons::Skip.to_button(),
]])),
}
}
/// Get the listing type selection message
pub fn get_listing_type_selection_message() -> &'static str {
"🛍️ <b>What type of listing would you like to create?</b>\n\n\
<b>🛍️ Fixed Price:</b> Set a fixed price for immediate purchase\n\
<b>⏰ Basic Auction:</b> Traditional time-based auction with bidding\n\
<b>🎭 Blind Auction:</b> Buyers submit sealed bids, you choose the winner\n\
<b>🎯 Multi-Slot Auction:</b> Multiple items/winners in one auction\n\n\
Choose your listing type:"
}
/// Get the keyboard for listing type selection
pub fn get_listing_type_keyboard() -> InlineKeyboardMarkup {
ListingTypeKeyboardButtons::to_keyboard().append_row([NavKeyboardButtons::Back.to_button()])
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
use std::str::FromStr;
use chrono::Duration;
use crate::{
assert_timestamps_approx_eq,
commands::new_listing::{
field_processing::update_field_on_draft,
types::{ListingDraft, ListingField},
},
db::{
listing::{FixedPriceListingFields, ListingFields},
CurrencyType, DbUserId, MoneyAmount,
},
};
fn create_test_draft() -> ListingDraft {
ListingDraft {
has_changes: false,
persisted: None,
base: crate::db::listing::ListingBase {
seller_id: DbUserId::new(1),
title: "".to_string(),
description: None,
currency_type: CurrencyType::Usd,
starts_at: chrono::Utc::now(),
ends_at: chrono::Utc::now() + chrono::Duration::hours(1),
is_active: true,
},
fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::default(),
slots_available: 0,
}),
}
}
// === Business Logic Tests ===
#[test]
fn test_complete_field_processing_workflow() {
let mut draft = create_test_draft();
// Process all fields in sequence with realistic inputs
let workflow = [
(ListingField::Title, "Handcrafted Wooden Bowl"),
(
ListingField::Description,
"Beautiful handmade oak bowl, perfect for serving",
),
(ListingField::Price, "34.99"),
(ListingField::Slots, "2"),
(ListingField::StartTime, "1 hour"),
(ListingField::EndTime, "3 days"),
];
for (field, input) in workflow {
let result = update_field_on_draft(field, &mut draft, Some(input));
assert!(result.is_ok(), "Processing {field:?} should succeed");
}
// Verify realistic final state
assert_eq!(draft.base.title, "Handcrafted Wooden Bowl");
assert!(draft
.base
.description
.as_ref()
.unwrap()
.contains("oak bowl"));
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
assert_eq!(
fields.buy_now_price,
MoneyAmount::from_str("34.99").unwrap()
);
assert_eq!(fields.slots_available, 2);
}
assert_timestamps_approx_eq!(
draft.base.starts_at,
chrono::Utc::now() + Duration::hours(1),
Duration::milliseconds(100)
);
assert_timestamps_approx_eq!(
draft.base.ends_at,
chrono::Utc::now() + Duration::hours(72) + Duration::hours(1),
Duration::milliseconds(100)
);
}
#[test]
fn test_natural_language_duration_conversion() {
let mut draft = create_test_draft();
// Test critical natural language parsing works in business context
let business_durations = [
("1 day", 24), // Common short listing
("7 days", 168), // Standard week-long listing
("30 days", 720), // Maximum allowed duration
];
for (input, expected_hours) in business_durations {
update_field_on_draft(ListingField::EndTime, &mut draft, Some(input)).unwrap();
assert_timestamps_approx_eq!(
draft.base.ends_at,
chrono::Utc::now() + chrono::Duration::hours(expected_hours),
Duration::milliseconds(100),
"Business duration '{input}' should convert correctly"
);
}
}
#[test]
fn test_price_and_slots_consistency_for_fixed_price_listings() {
let mut draft = create_test_draft();
// Test that price and slots work together correctly for business logic
update_field_on_draft(ListingField::Price, &mut draft, Some("25.00")).unwrap();
update_field_on_draft(ListingField::Slots, &mut draft, Some("5")).unwrap();
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
assert_eq!(
fields.buy_now_price,
MoneyAmount::from_str("25.00").unwrap()
);
assert_eq!(fields.slots_available, 5);
// Business logic: Total potential value is price * slots
let total_value = fields.buy_now_price.cents() * fields.slots_available as i64;
assert_eq!(total_value, 12500); // $25.00 * 5 = $125.00 (12500 cents)
}
}

View File

@@ -1,32 +1,102 @@
use crate::{
db::{ListingDuration, MoneyAmount},
db::{
listing::{
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase,
ListingFields, MultiSlotAuctionFields, PersistedListing, PersistedListingFields,
},
CurrencyType, DbUserId, ListingType, MoneyAmount,
},
DialogueRootState,
};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ListingDraft {
pub title: String,
pub description: Option<String>,
pub buy_now_price: MoneyAmount,
pub slots_available: i32,
pub start_delay: ListingDuration,
pub duration: ListingDuration,
pub has_changes: bool,
pub persisted: Option<PersistedListingFields>,
pub base: ListingBase,
pub fields: ListingFields,
}
impl ListingDraft {
pub fn new_for_seller_with_type(seller_id: DbUserId, listing_type: ListingType) -> Self {
let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::default(),
buy_now_price: None,
min_increment: MoneyAmount::from_cents(100), // Default $1.00 increment
anti_snipe_minutes: Some(5),
}),
ListingType::MultiSlotAuction => {
ListingFields::MultiSlotAuction(MultiSlotAuctionFields {
starting_bid: MoneyAmount::default(),
buy_now_price: MoneyAmount::default(),
min_increment: Some(MoneyAmount::from_cents(100)), // Default $1.00 increment
slots_available: 1,
anti_snipe_minutes: 5,
})
}
ListingType::FixedPriceListing => {
ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::default(),
slots_available: 1,
})
}
ListingType::BlindAuction => ListingFields::BlindAuction(BlindAuctionFields {
starting_bid: MoneyAmount::default(),
}),
};
Self {
has_changes: false,
persisted: None,
base: ListingBase {
seller_id,
currency_type: CurrencyType::Usd,
title: "".to_string(),
description: None,
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
is_active: true,
},
fields,
}
}
pub fn from_persisted(listing: PersistedListing) -> Self {
Self {
has_changes: false,
persisted: Some(listing.persisted),
base: listing.base,
fields: listing.fields,
}
}
pub fn listing_type(&self) -> ListingType {
(&self.fields).into()
}
}
#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
pub enum ListingField {
Title,
Description,
CurrencyType,
Price,
Slots,
StartTime,
Duration,
EndTime,
StartingBidAmount,
BuyNowPrice,
MinBidIncrement,
AntiSnipeMinutes,
}
// Dialogue state for the new listing wizard
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum NewListingState {
SelectingListingType,
AwaitingDraftField {
field: ListingField,
draft: ListingDraft,

View File

@@ -0,0 +1,85 @@
//! UI display functions for the new listing wizard
//!
//! This module handles all user interface display logic including
//! listing summaries, confirmation screens, and edit interfaces.
use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons;
use crate::commands::new_listing::messages::steps_for_listing_type;
use crate::commands::new_listing::NewListingState;
use crate::db::ListingType;
use crate::App;
use crate::RootDialogue;
use crate::{commands::new_listing::types::ListingDraft, BotResult};
use anyhow::Context;
use teloxide::types::InlineKeyboardMarkup;
/// Display the listing summary with optional flash message and keyboard
pub async fn display_listing_summary(
app: App,
draft: &ListingDraft,
keyboard: Option<InlineKeyboardMarkup>,
flash_message: Option<String>,
) -> BotResult {
let mut response_lines = vec![];
let listing_type: ListingType = (&draft.fields).into();
if let Some(flash_message) = flash_message {
response_lines.push(flash_message.to_string());
}
let unsaved_changes = if draft.has_changes {
"<i>Unsaved changes</i>"
} else {
""
};
response_lines.push(format!(
"📋 <b>{listing_type} Summary</b> {unsaved_changes}"
));
response_lines.push("".to_string());
for step in steps_for_listing_type(listing_type) {
let field_value = match (step.get_field_value)(draft) {
Ok(value) => value.unwrap_or_else(|| "(none)".to_string()),
Err(_) => continue,
};
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
}
response_lines.push("".to_string());
response_lines.push("Edit your listing:".to_string());
app.bot
.send_html_message(response_lines.join("\n"), keyboard)
.await?;
Ok(())
}
/// Show the final confirmation screen before creating/saving the listing
pub async fn enter_confirm_save_listing(
app: App,
dialogue: RootDialogue,
draft: ListingDraft,
flash: Option<String>,
) -> BotResult {
let keyboard = if draft.persisted.is_some() {
InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Save.to_button(),
ConfirmationKeyboardButtons::Edit.to_button(),
ConfirmationKeyboardButtons::Discard.to_button(),
])
} else {
InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Create.to_button(),
ConfirmationKeyboardButtons::Edit.to_button(),
ConfirmationKeyboardButtons::Cancel.to_button(),
])
};
display_listing_summary(app, &draft, Some(keyboard), flash).await?;
dialogue
.update(NewListingState::ViewingDraft(draft))
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

@@ -1,74 +1,179 @@
use crate::db::{ListingDuration, MoneyAmount};
use std::str::FromStr;
use chrono::{DateTime, Duration, Utc};
use crate::db::{CurrencyType, MoneyAmount};
#[derive(Debug)]
pub enum SetFieldError {
FieldRequired,
ValidationFailed(String),
UnsupportedFieldForListingType,
}
type SetFieldResult<T> = Result<T, SetFieldError>;
fn validation_failed<T>(message: &str) -> SetFieldResult<T> {
Err(SetFieldError::ValidationFailed(message.to_string()))
}
macro_rules! validation_failed {
($message:expr) => {
return validation_failed($message)
};
}
pub fn require_field(field: Option<String>) -> SetFieldResult<String> {
field.ok_or(SetFieldError::FieldRequired)
}
// Common input validation functions
pub fn validate_title(text: &str) -> Result<String, String> {
pub fn validate_title(text: impl AsRef<str>) -> SetFieldResult<String> {
let text = text.as_ref();
if text.is_empty() {
return Err("❌ Title cannot be empty. Please enter a title for your listing:".to_string());
validation_failed!("❌ Title cannot be empty");
}
if text.len() > 100 {
return Err(
"❌ Title is too long (max 100 characters). Please enter a shorter title:".to_string(),
);
validation_failed!("❌ Title is too long (max 100 characters)");
}
Ok(text.to_string())
}
pub fn validate_description(text: &str) -> Result<String, String> {
pub fn validate_description(text: impl AsRef<str>) -> SetFieldResult<String> {
let text = text.as_ref();
if text.len() > 1000 {
return Err(
"❌ Description is too long (max 1000 characters). Please enter a shorter description:"
.to_string(),
);
validation_failed!("❌ Description is too long (max 1000 characters)");
}
Ok(text.to_string())
}
pub fn validate_price(text: &str) -> Result<MoneyAmount, String> {
pub fn validate_currency_type(text: impl AsRef<str>) -> SetFieldResult<CurrencyType> {
let text = text.as_ref();
if let Ok(currency_type) = CurrencyType::try_from(text) {
Ok(currency_type)
} else {
validation_failed!("❌ Invalid currency type");
}
}
pub fn validate_price(text: impl AsRef<str>) -> SetFieldResult<MoneyAmount> {
let text = text.as_ref();
match MoneyAmount::from_str(text) {
Ok(amount) => {
if amount.cents() <= 0 {
Err("❌ Price must be greater than $0.00. Please enter a valid price:".to_string())
validation_failed!("❌ Price must be greater than $0.00");
} else {
Ok(amount)
}
}
Err(_) => Err(
"❌ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):"
.to_string(),
Err(_) => validation_failed!(
"❌ Invalid price format (use decimal format, e.g., 10.50, 25, 0.99):"
),
}
}
pub fn validate_slots(text: &str) -> Result<i32, String> {
pub fn validate_slots(text: impl AsRef<str>) -> SetFieldResult<i32> {
let text = text.as_ref();
match text.parse::<i32>() {
Ok(slots) if (1..=1000).contains(&slots) => Ok(slots),
Ok(_) => Err(
"❌ Number of slots must be between 1 and 1000. Please enter a valid number:"
.to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter a number from 1 to 1000:".to_string()),
Ok(_) => validation_failed!("❌ Number of slots must be between 1 and 1000"),
Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 1 to 1000"),
}
}
pub fn validate_duration(text: &str) -> Result<ListingDuration, String> {
match text.parse::<i32>() {
Ok(hours) if (1..=720).contains(&hours) => Ok(ListingDuration::hours(hours)), // 1 hour to 30 days
Ok(_) => Err(
"❌ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter number of hours (1-720):".to_string()),
pub fn validate_time(text: impl AsRef<str>, field_name: &str) -> SetFieldResult<Duration> {
let text = text.as_ref();
let text = text.trim().to_lowercase();
// Try to parse as plain number first (backwards compatibility)
if let Ok(hours) = text.parse::<i32>() {
if (1..=720).contains(&hours) {
return Ok(Duration::hours(hours as i64));
} else {
validation_failed!(&format!(
"{field_name} must be between 1 hour and 30 days (720 hours)"
));
}
}
// Parse natural language duration
let parts: Vec<&str> = text.split_whitespace().collect();
let (number_str, unit) = if parts.len() == 2 {
(parts[0], parts[1])
} else if parts.len() == 1 {
(text.as_str(), "hour")
} else {
validation_failed!(&format!(
"❌ Please enter {field_name} like '1 hour', '7 days', or just hours (1-720)"
));
};
let number = match number_str.parse::<i32>() {
Ok(n) if n > 0 => n,
_ => validation_failed!(&format!(
"{field_name} number must be a positive integer"
)),
};
let hours = match unit {
"hour" | "hours" | "hr" | "hrs" | "h" | "hs" => number,
"day" | "days" | "d" | "ds" => number * 24,
_ => validation_failed!("❌ Supported units: hour(s), day(s)"),
};
if (1..=720).contains(&hours) {
Ok(Duration::hours(hours as i64))
} else {
validation_failed!(&format!(
"{field_name} must be between 1 hour and 30 days (720 hours)"
));
}
}
pub fn validate_start_time(text: &str) -> Result<ListingDuration, String> {
pub fn validate_duration(text: impl AsRef<str>) -> SetFieldResult<Duration> {
validate_time(text, "Duration")
}
pub fn validate_start_time(text: impl AsRef<str>) -> SetFieldResult<DateTime<Utc>> {
validate_time(text, "Start Time").map(|duration| Utc::now() + duration)
}
pub fn validate_anti_snipe_minutes(text: impl AsRef<str>) -> SetFieldResult<i32> {
let text = text.as_ref();
match text.parse::<i32>() {
Ok(hours) if (0..=168).contains(&hours) => Ok(ListingDuration::hours(hours)), // Max 1 week delay
Ok(_) => Err(
"❌ Start time must be between 0 and 168 hours. Please enter a valid number:"
.to_string(),
),
Err(_) => Err(
"❌ Invalid number. Please enter number of hours (0 for immediate start):".to_string(),
),
Ok(minutes) if (0..=1440).contains(&minutes) => Ok(minutes),
Ok(_) => validation_failed!("❌ Anti-snipe minutes must be between 0 and 1440"),
Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 0 to 1440"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("24", Duration::hours(24))] // Plain number
#[case("1 hour", Duration::hours(1))]
#[case("2 hours", Duration::hours(2))]
#[case("1 day", Duration::hours(24))]
#[case("7 days", Duration::hours(168))]
#[case("30 days", Duration::hours(720))] // Max 30 days
fn test_validate_duration_valid(#[case] input: &str, #[case] expected: Duration) {
let result = validate_duration(input).unwrap();
assert_eq!(result, expected);
}
#[rstest]
#[case("0")]
#[case("0 hours")]
#[case("721")] // Over limit
#[case("1 week")] // Unsupported unit
#[case("1 month")] // Unsupported unit
#[case("1 year")] // Unsupported unit
#[case("abc")] // Invalid text
#[case("-1 hour")] // Negative
fn test_validate_duration_invalid(#[case] input: &str) {
assert!(validate_duration(input).is_err());
}
}

View File

@@ -1,16 +1,16 @@
use crate::{App, BotResult};
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::types::Message;
use crate::HandlerResult;
pub async fn handle_settings(bot: Bot, msg: Message) -> HandlerResult {
pub async fn handle_settings(app: App, msg: Message) -> BotResult {
let response = "⚙️ Settings (Coming Soon)\n\n\
Here you'll be able to configure:\n\
• Notification preferences\n\
• Language settings\n\
• Default bid increments\n\
• Outbid alerts\n\n\
Feature in development! 🛠️";
Feature in development! 🛠️"
.to_string();
info!(
"User {} ({}) accessed settings",
@@ -18,6 +18,6 @@ pub async fn handle_settings(bot: Bot, msg: Message) -> HandlerResult {
msg.chat.id
);
bot.send_message(msg.chat.id, response).await?;
app.bot.send_html_message(response, None).await?;
Ok(())
}

View File

@@ -1,24 +1,119 @@
use anyhow::Context;
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::{
types::{CallbackQuery, Update},
utils::command::BotCommands,
};
use crate::HandlerResult;
use crate::{
commands::my_listings::enter_my_listings, db::user::PersistedUser, keyboard_buttons,
message_utils::extract_callback_data, App, BotResult, Command, DialogueRootState, RootDialogue,
};
pub async fn handle_start(bot: Bot, msg: Message) -> HandlerResult {
let welcome_message = "🎯 Welcome to Pawctioneer Bot! 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\
• Multi-slot auctions (multiple winners)\n\
• Fixed price sales\n\
• Blind auctions\n\n\
Use /help to see all available commands.\n\n\
Ready to start your auction experience? 🚀";
keyboard_buttons! {
pub enum MainMenuButtons {
[
MyListings("📋 My Listings", "menu_my_listings"),
MyBids("💰 My Bids", "menu_my_bids"),
],
[
Settings("⚙️ Settings", "menu_settings"),
Help("❓ Help", "menu_help"),
]
}
}
info!(
"User {} ({}) started the bot",
msg.chat.username().unwrap_or("unknown"),
msg.chat.id
);
/// Get the main menu welcome message
fn get_main_menu_message() -> &'static str {
"🎯 <b>Welcome to Pawctioneer Bot!</b> 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\
• Multi-slot auctions (multiple winners)\n\
• Fixed price sales\n\
• Blind auctions\n\n\
Choose an option below to get started! 🚀"
}
pub async fn handle_start(app: App, dialogue: RootDialogue, update: Update) -> BotResult {
info!("got start message: {update:?}");
enter_main_menu(app, dialogue).await?;
Ok(())
}
/// Show the main menu with buttons
pub async fn enter_main_menu(app: App, dialogue: RootDialogue) -> BotResult {
dialogue
.update(DialogueRootState::MainMenu)
.await
.context("failed to update dialogue")?;
app.bot
.send_html_message(
get_main_menu_message().to_string(),
Some(MainMenuButtons::to_keyboard()),
)
.await?;
Ok(())
}
pub async fn handle_main_menu_callback(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
callback_query: CallbackQuery,
) -> BotResult {
let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User selected main menu option: {data:?}");
let button = MainMenuButtons::try_from(data.as_str())?;
match button {
MainMenuButtons::MyListings => {
// Call show_listings_for_user directly
enter_my_listings(app, dialogue, user, None).await?;
}
MainMenuButtons::MyBids => {
app.bot
.send_html_message(
"💰 <b>My Bids (Coming Soon)</b>\n\n\
Here you'll be able to view:\n\
• Your active bids\n\
• Bid history\n\
• Won/lost auctions\n\
• Outbid notifications\n\n\
Feature in development! 🛠️"
.to_string(),
Some(MainMenuButtons::to_keyboard()),
)
.await?;
}
MainMenuButtons::Settings => {
app.bot
.send_html_message(
"⚙️ <b>Settings (Coming Soon)</b>\n\n\
Here you'll be able to configure:\n\
• Notification preferences\n\
• Language settings\n\
• Default bid increments\n\
• Outbid alerts\n\n\
Feature in development! 🛠️"
.to_string(),
Some(MainMenuButtons::to_keyboard()),
)
.await?;
}
MainMenuButtons::Help => {
let help_message = format!(
"📋 <b>Available Commands:</b>\n\n{}\n\n\
📧 <b>Support:</b> Contact @admin for help\n\
🔗 <b>More info:</b> Use individual commands to get started!",
Command::descriptions()
);
app.bot
.send_html_message(help_message, Some(MainMenuButtons::to_keyboard()))
.await?;
}
}
bot.send_message(msg.chat.id, welcome_message).await?;
Ok(())
}

59
src/db/bind_fields.rs Normal file
View File

@@ -0,0 +1,59 @@
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))
}
#[derive(Default)]
pub struct BindFields {
binds: Vec<(&'static str, BindFn)>,
}
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> + '_ {
std::iter::repeat_n("?", self.binds.len())
}
}

272
src/db/dao/bid_dao.rs Normal file
View File

@@ -0,0 +1,272 @@
use crate::db::{
bid::{NewBid, PersistedBid, PersistedBidFields},
bind_fields::BindFields,
DbListingId, DbUserId,
};
use anyhow::Result;
use chrono::Utc;
use itertools::Itertools as _;
use sqlx::{prelude::*, sqlite::SqliteRow, SqlitePool};
#[derive(Clone)]
pub struct BidDAO(SqlitePool);
impl BidDAO {
pub fn new(pool: SqlitePool) -> Self {
Self(pool)
}
}
#[allow(unused)]
impl BidDAO {
pub async fn insert_bid(&self, bid: &NewBid) -> Result<PersistedBid> {
let mut tx = self.0.begin().await?;
// First, validate that the listing is active within the transaction
// Note: SQLite doesn't support FOR UPDATE, but transactions provide isolation
let is_active: bool = sqlx::query_scalar("SELECT is_active FROM listings WHERE id = ?")
.bind(bid.listing_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| anyhow::anyhow!("Listing not found"))?;
if !is_active {
return Err(anyhow::anyhow!("Cannot place bid on inactive listing"));
}
let now = Utc::now();
let binds = BindFields::default()
.push("listing_id", &bid.listing_id)
.push("buyer_id", &bid.buyer_id)
.push("bid_amount", &bid.bid_amount)
.push("description", &bid.description)
.push("is_cancelled", &bid.is_cancelled)
.push("slot_number", &bid.slot_number)
.push("proxy_bid_id", &bid.proxy_bid_id)
.push("created_at", &now)
.push("updated_at", &now);
let insert_bid_query_str = format!(
r#"
INSERT INTO bids ({}) VALUES ({})
RETURNING *
"#,
binds.bind_names().join(", "),
binds.bind_placeholders().join(", ")
);
let insert_bid_query = binds.bind_to_query(sqlx::query(&insert_bid_query_str));
let row = insert_bid_query.fetch_one(&mut *tx).await?;
tx.commit().await?;
Ok(PersistedBid::from_row(&row)?)
}
pub async fn bidder_ids_for_listing(&self, listing_id: DbListingId) -> Result<Vec<DbUserId>> {
let rows =
sqlx::query_as::<_, (DbUserId,)>("SELECT buyer_id FROM bids WHERE listing_id = ?")
.bind(listing_id)
.fetch_all(&self.0)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
pub async fn bids_for_listing(&self, listing_id: DbListingId) -> Result<Vec<PersistedBid>> {
let rows = sqlx::query_as::<_, PersistedBid>(
"SELECT * FROM bids WHERE listing_id = ? ORDER BY bid_amount DESC",
)
.bind(listing_id)
.fetch_all(&self.0)
.await?;
Ok(rows)
}
}
impl FromRow<'_, SqliteRow> for PersistedBid {
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
Ok(PersistedBid {
persisted: PersistedBidFields::from_row(row)?,
listing_id: row.get("listing_id"),
buyer_id: row.get("buyer_id"),
bid_amount: row.get("bid_amount"),
description: row.get("description"),
is_cancelled: row.get("is_cancelled"),
slot_number: row.get("slot_number"),
proxy_bid_id: row.get("proxy_bid_id"),
})
}
}
impl FromRow<'_, SqliteRow> for PersistedBidFields {
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
Ok(PersistedBidFields {
id: row.get("id"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::db::{
dao::listing_dao::{ListingEventSender, ListingUpdatedEvent},
listing::{BasicAuctionFields, ListingFields},
models::{listing::NewListing, user::NewUser},
CurrencyType, ListingDAO, MoneyAmount, UserDAO,
};
use crate::test_utils::create_test_pool;
use chrono::Utc;
use teloxide::types::UserId;
async fn create_test_user_and_listing() -> (
UserDAO,
ListingDAO,
BidDAO,
crate::db::DbUserId,
crate::db::DbListingId,
) {
let (tx, _) = tokio::sync::mpsc::channel::<ListingUpdatedEvent>(1);
let pool = create_test_pool().await;
let user_dao = UserDAO::new(pool.clone());
let listing_dao = ListingDAO::new(pool.clone(), ListingEventSender::new(tx));
let bid_dao = BidDAO::new(pool);
// Create a test user
let new_user = NewUser {
persisted: (),
telegram_id: UserId(12345).into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
is_banned: false,
};
let user = user_dao
.insert_user(&new_user)
.await
.expect("Failed to insert test user");
// Create a test listing
let new_listing = NewListing {
persisted: (),
base: crate::db::listing::ListingBase {
seller_id: user.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
currency_type: CurrencyType::Usd,
starts_at: Utc::now(),
ends_at: Utc::now() + chrono::Duration::hours(24),
is_active: true,
},
fields: ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("10.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),
}),
};
let listing = listing_dao
.insert_listing(&new_listing)
.await
.expect("Failed to insert test listing");
(
user_dao,
listing_dao,
bid_dao,
user.persisted.id,
listing.persisted.id,
)
}
#[tokio::test]
async fn test_insert_bid_on_active_listing() {
let (_user_dao, _listing_dao, bid_dao, user_id, listing_id) =
create_test_user_and_listing().await;
let new_bid = NewBid {
persisted: (),
listing_id,
buyer_id: user_id,
bid_amount: MoneyAmount::from_str("25.50").unwrap(),
description: Some("Test bid description".to_string()),
is_cancelled: false,
slot_number: Some(1),
proxy_bid_id: None,
};
// Insert bid on active listing should succeed
let inserted_bid = bid_dao
.insert_bid(&new_bid)
.await
.expect("Failed to insert bid on active listing");
// Verify the inserted bid has the correct values
assert_eq!(inserted_bid.listing_id, new_bid.listing_id);
assert_eq!(inserted_bid.buyer_id, new_bid.buyer_id);
assert_eq!(inserted_bid.bid_amount, new_bid.bid_amount);
assert_eq!(inserted_bid.description, new_bid.description);
assert_eq!(inserted_bid.is_cancelled, new_bid.is_cancelled);
assert_eq!(inserted_bid.slot_number, new_bid.slot_number);
assert_eq!(inserted_bid.proxy_bid_id, new_bid.proxy_bid_id);
// Verify persisted fields are populated
assert!(inserted_bid.persisted.id.get() > 0);
assert!(inserted_bid.persisted.created_at <= chrono::Utc::now());
assert!(inserted_bid.persisted.updated_at <= chrono::Utc::now());
assert_eq!(
inserted_bid.persisted.created_at,
inserted_bid.persisted.updated_at
);
}
#[tokio::test]
async fn test_insert_bid_on_inactive_listing_fails() {
let (_user_dao, listing_dao, bid_dao, user_id, listing_id) =
create_test_user_and_listing().await;
// Mark the listing as inactive
listing_dao
.set_listing_is_active(listing_id, false)
.await
.unwrap();
let new_bid = NewBid {
persisted: (),
listing_id,
buyer_id: user_id,
bid_amount: MoneyAmount::from_str("25.50").unwrap(),
description: Some("Test bid description".to_string()),
is_cancelled: false,
slot_number: Some(1),
proxy_bid_id: None,
};
// Verify the listing is actually inactive
let updated_listing = listing_dao.find_by_id(listing_id).await.unwrap().unwrap();
assert!(
!updated_listing.base.is_active,
"Listing should be inactive"
);
// Insert bid on inactive listing should fail
let result = bid_dao.insert_bid(&new_bid).await;
match result {
Ok(_) => panic!("Expected bid insertion to fail on inactive listing, but it succeeded"),
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("inactive listing")
|| error_msg.contains("Cannot place bid"),
"Expected error about inactive listing, got: {}",
error_msg
);
}
}
}
}

View File

@@ -3,178 +3,445 @@
//! Provides encapsulated CRUD operations for Listing entities
use anyhow::Result;
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
use chrono::Utc;
use itertools::Itertools;
use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool};
use std::fmt::Debug;
use tokio::sync::mpsc::{Receiver, Sender};
use crate::db::{
new_listing::{NewListing, NewListingFields},
ListingBase, ListingFields,
bind_fields::BindFields,
listing::{
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, Listing, ListingBase,
ListingFields, MultiSlotAuctionFields, NewListing, PersistedListing,
PersistedListingFields,
},
DbListingId, DbUserId, ListingType,
};
use super::super::{Listing, ListingId, ListingType, UserRowId};
#[derive(Debug, Clone, Copy)]
pub enum ListingUpdatedEvent {
Created(DbListingId),
Updated(DbListingId),
Deleted(DbListingId),
}
#[derive(Clone)]
pub struct ListingEventSender(Sender<ListingUpdatedEvent>);
impl ListingEventSender {
pub fn new(sender: Sender<ListingUpdatedEvent>) -> Self {
Self(sender)
}
pub fn channel() -> (ListingEventSender, Receiver<ListingUpdatedEvent>) {
let (sender, receiver) = tokio::sync::mpsc::channel::<ListingUpdatedEvent>(1);
(ListingEventSender::new(sender), receiver)
}
pub fn send(&self, event: ListingUpdatedEvent) {
if let Err(e) = self.0.try_send(event) {
log::error!("Error sending listing updated event: {e:?}");
}
}
}
/// Data Access Object for Listing operations
pub struct ListingDAO;
#[derive(Clone)]
pub struct ListingDAO(SqlitePool, ListingEventSender);
impl ListingDAO {
pub fn new(pool: SqlitePool, sender: ListingEventSender) -> Self {
Self(pool, sender)
}
fn send_event(&self, event: ListingUpdatedEvent) {
self.1.send(event);
}
/// Insert a new listing into the database
pub async fn insert_listing(pool: &SqlitePool, new_listing: &NewListing) -> Result<Listing> {
let listing_type = new_listing.listing_type();
let base = &new_listing.base;
let fields = &new_listing.fields;
pub async fn insert_listing(&self, listing: &NewListing) -> Result<PersistedListing> {
let now = Utc::now();
let base_query = match listing_type {
ListingType::BasicAuction => sqlx::query(
r#"
INSERT INTO listings (
seller_id, listing_type, title, description, starts_at, ends_at,
starting_bid, buy_now_price, min_increment, 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, 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 binds = binds_for_listing(listing)
.push("seller_id", &listing.base.seller_id)
.push("starts_at", &listing.base.starts_at)
.push("ends_at", &listing.base.ends_at)
.push("created_at", &now)
.push("updated_at", &now);
let row = base_query
.bind(base.seller_id)
.bind(listing_type)
.bind(&base.title)
.bind(&base.description)
.bind(base.starts_at)
.bind(base.ends_at);
let query_str = format!(
r#"
INSERT INTO listings ({}) VALUES ({})
RETURNING *
"#,
binds.bind_names().join(", "),
binds.bind_placeholders().join(", "),
);
let row = match &fields {
NewListingFields::BasicAuction {
starting_bid,
buy_now_price,
min_increment,
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 = binds
.bind_to_query(sqlx::query(&query_str))
.fetch_one(&self.0)
.await?;
let listing = PersistedListing::from_row(&row)?;
self.send_event(ListingUpdatedEvent::Created(listing.persisted.id));
Ok(listing)
}
let row = row.fetch_one(pool).await?;
Self::row_to_listing(row)
pub async fn update_listing(&self, 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(", "),
);
let row = binds
.bind_to_query(sqlx::query(&query_str))
.bind(listing.persisted.id)
.bind(listing.base.seller_id)
.fetch_one(&self.0)
.await?;
let listing = PersistedListing::from_row(&row)?;
self.send_event(ListingUpdatedEvent::Updated(listing.persisted.id));
Ok(listing)
}
/// Find a listing by its ID
pub async fn find_by_id(pool: &SqlitePool, listing_id: ListingId) -> Result<Option<Listing>> {
let result = sqlx::query("SELECT * FROM listings WHERE id = ?")
pub async fn find_by_id(&self, listing_id: DbListingId) -> Result<Option<PersistedListing>> {
let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?")
.bind(listing_id)
.fetch_optional(pool)
.fetch_optional(&self.0)
.await?;
result.map(Self::row_to_listing).transpose()
Ok(result)
}
/// 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(&self, seller_id: DbUserId) -> Result<Vec<PersistedListing>> {
let rows =
sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
.bind(seller_id)
.fetch_all(pool)
.fetch_all(&self.0)
.await?;
rows
.into_iter()
.map(Self::row_to_listing)
.collect::<Result<Vec<_>>>()
Ok(rows)
}
/// Delete a listing
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> {
pub async fn delete_listing(&self, listing_id: DbListingId) -> Result<()> {
sqlx::query("DELETE FROM listings WHERE id = ?")
.bind(listing_id)
.execute(pool)
.execute(&self.0)
.await?;
self.send_event(ListingUpdatedEvent::Deleted(listing_id));
Ok(())
}
fn row_to_listing(row: SqliteRow) -> Result<Listing> {
pub async fn set_listing_is_active(
&self,
listing_id: DbListingId,
is_active: bool,
) -> Result<PersistedListing> {
let result = sqlx::query_as::<_, PersistedListing>(
r#"
UPDATE listings
SET is_active = ?
WHERE id = ?
RETURNING *"#,
)
.bind(is_active)
.bind(listing_id)
.fetch_one(&self.0)
.await?;
self.send_event(ListingUpdatedEvent::Updated(result.persisted.id));
Ok(result)
}
pub async fn find_next_ending_listing(&self) -> Result<Option<PersistedListing>> {
let result = sqlx::query_as(
r#"
SELECT * FROM listings
WHERE is_active = 1
ORDER BY ends_at ASC
LIMIT 1"#,
)
.fetch_optional(&self.0)
.await?;
Ok(result)
}
}
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)
.push("currency_type", &base.currency_type)
.push("starts_at", &base.starts_at)
.push("ends_at", &base.ends_at)
.push("is_active", &base.is_active)
}
fn binds_for_fields(fields: &ListingFields) -> BindFields {
match fields {
ListingFields::BasicAuction(fields) => BindFields::default()
.push("listing_type", &ListingType::BasicAuction)
.push("starting_bid", &fields.starting_bid)
.push("buy_now_price", &fields.buy_now_price)
.push("min_increment", &fields.min_increment)
.push("anti_snipe_minutes", &fields.anti_snipe_minutes),
ListingFields::MultiSlotAuction(fields) => BindFields::default()
.push("listing_type", &ListingType::MultiSlotAuction)
.push("starting_bid", &fields.starting_bid)
.push("buy_now_price", &fields.buy_now_price)
.push("min_increment", &fields.min_increment)
.push("slots_available", &fields.slots_available)
.push("anti_snipe_minutes", &fields.anti_snipe_minutes),
ListingFields::FixedPriceListing(fields) => BindFields::default()
.push("listing_type", &ListingType::FixedPriceListing)
.push("buy_now_price", &fields.buy_now_price)
.push("slots_available", &fields.slots_available),
ListingFields::BlindAuction(fields) => BindFields::default()
.push("listing_type", &ListingType::BlindAuction)
.push("starting_bid", &fields.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 base = ListingBase {
id: ListingId::new(row.get("id")),
seller_id: row.get("seller_id"),
title: row.get("title"),
description: row.get("description"),
starts_at: row.get("starts_at"),
ends_at: row.get("ends_at"),
let persisted = PersistedListingFields {
id: row.get("id"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
};
let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction {
starting_bid: row.get("starting_bid"),
buy_now_price: row.get("buy_now_price"),
min_increment: row.get("min_increment"),
anti_snipe_minutes: row.get("anti_snipe_minutes"),
},
ListingType::MultiSlotAuction => ListingFields::MultiSlotAuction {
starting_bid: row.get("starting_bid"),
buy_now_price: row.get("buy_now_price"),
min_increment: row.get("min_increment"),
slots_available: row.get("slots_available"),
anti_snipe_minutes: row.get("anti_snipe_minutes"),
},
ListingType::FixedPriceListing => ListingFields::FixedPriceListing {
buy_now_price: row.get("buy_now_price"),
slots_available: row.get("slots_available"),
},
ListingType::BlindAuction => ListingFields::BlindAuction {
starting_bid: row.get("starting_bid"),
},
let base = ListingBase {
seller_id: row.get("seller_id"),
title: row.get("title"),
description: row.get("description"),
currency_type: row.get("currency_type"),
starts_at: row.get("starts_at"),
ends_at: row.get("ends_at"),
is_active: row.get("is_active"),
};
Ok(Listing { base, fields })
let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: row.get("starting_bid"),
buy_now_price: row.get("buy_now_price"),
min_increment: row.get("min_increment"),
anti_snipe_minutes: row.get("anti_snipe_minutes"),
}),
ListingType::MultiSlotAuction => {
ListingFields::MultiSlotAuction(MultiSlotAuctionFields {
starting_bid: row.get("starting_bid"),
buy_now_price: row.get("buy_now_price"),
min_increment: row.get("min_increment"),
slots_available: row.get("slots_available"),
anti_snipe_minutes: row.get("anti_snipe_minutes"),
})
}
ListingType::FixedPriceListing => {
ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: row.get("buy_now_price"),
slots_available: row.get("slots_available"),
})
}
ListingType::BlindAuction => ListingFields::BlindAuction(BlindAuctionFields {
starting_bid: row.get("starting_bid"),
}),
};
Ok(PersistedListing {
persisted,
base,
fields,
})
}
}
#[cfg(test)]
mod tests {
use crate::test_utils::{create_deps, with_test_listing, with_test_user};
use chrono::{Duration, Utc};
use rstest::rstest;
#[tokio::test]
async fn test_find_next_ending_listing_no_active_listings() {
let deps = create_deps().await;
let listing_dao = &deps.deps.get::<crate::db::DAOs>().listing;
let result = listing_dao
.find_next_ending_listing()
.await
.expect("Failed to query for next ending listing");
assert!(
result.is_none(),
"Expected no listings when database is empty"
);
}
#[tokio::test]
async fn test_find_next_ending_listing_single_active_listing() {
let deps = create_deps().await;
let listing_dao = &deps.deps.get::<crate::db::DAOs>().listing;
let seller = with_test_user(&deps.deps, |_| {}).await;
let ends_at = Utc::now() + Duration::hours(1);
let inserted_listing = with_test_listing(&deps.deps, &seller, |listing| {
listing.base.ends_at = ends_at;
})
.await;
let result = listing_dao
.find_next_ending_listing()
.await
.expect("Failed to query for next ending listing");
assert!(result.is_some(), "Expected to find the active listing");
let found_listing = result.unwrap();
assert_eq!(found_listing.persisted.id, inserted_listing.persisted.id);
assert_eq!(found_listing.base.ends_at, ends_at);
assert!(found_listing.base.is_active);
}
#[rstest]
#[case(3, vec![2, 1, 3], 1)] // Multiple listings, should return the one ending soonest (index 1)
#[case(2, vec![1, 2], 0)] // Two listings, should return the one ending first (index 0)
#[case(4, vec![4, 1, 3, 2], 1)] // Four listings, should return the one ending soonest (index 1)
#[tokio::test]
async fn test_find_next_ending_listing_multiple_active_listings(
#[case] _num_listings: usize,
#[case] hours_from_now: Vec<i64>,
#[case] expected_index: usize,
) {
let deps = create_deps().await;
let listing_dao = &deps.deps.get::<crate::db::DAOs>().listing;
let seller = with_test_user(&deps.deps, |_| {}).await;
let mut inserted_listings = Vec::new();
let base_time = Utc::now();
// Create multiple listings with different end times
for (_i, hours) in hours_from_now.iter().enumerate() {
let ends_at = base_time + Duration::hours(*hours);
let inserted_listing = with_test_listing(&deps.deps, &seller, |listing| {
listing.base.ends_at = ends_at;
})
.await;
inserted_listings.push(inserted_listing);
}
let result = listing_dao
.find_next_ending_listing()
.await
.expect("Failed to query for next ending listing");
assert!(result.is_some(), "Expected to find an active listing");
let found_listing = result.unwrap();
// Should return the listing that ends soonest
let expected_listing = &inserted_listings[expected_index];
assert_eq!(found_listing.persisted.id, expected_listing.persisted.id);
assert_eq!(found_listing.base.ends_at, expected_listing.base.ends_at);
}
#[tokio::test]
async fn test_find_next_ending_listing_ignores_inactive_listings() {
let deps = create_deps().await;
let listing_dao = &deps.deps.get::<crate::db::DAOs>().listing;
let seller = with_test_user(&deps.deps, |_| {}).await;
// Create an inactive listing that ends sooner
let inactive_ends_at = Utc::now() + Duration::hours(1);
with_test_listing(&deps.deps, &seller, |listing| {
listing.base.ends_at = inactive_ends_at;
listing.base.is_active = false;
})
.await;
// Create an active listing that ends later
let active_ends_at = Utc::now() + Duration::hours(2);
let inserted_active_listing = with_test_listing(&deps.deps, &seller, |listing| {
listing.base.ends_at = active_ends_at;
})
.await;
let result = listing_dao
.find_next_ending_listing()
.await
.expect("Failed to query for next ending listing");
assert!(result.is_some(), "Expected to find the active listing");
let found_listing = result.unwrap();
// Should return the active listing, not the inactive one that ends sooner
assert_eq!(
found_listing.persisted.id,
inserted_active_listing.persisted.id
);
assert_eq!(found_listing.base.ends_at, active_ends_at);
assert!(found_listing.base.is_active);
}
#[tokio::test]
async fn test_find_next_ending_listing_with_mixed_active_inactive() {
let deps = create_deps().await;
let listing_dao = &deps.deps.get::<crate::db::DAOs>().listing;
let seller = with_test_user(&deps.deps, |_| {}).await;
let base_time = Utc::now();
let mut inserted_active_listings = Vec::new();
// Create a mix of active and inactive listings
let listing_configs = vec![
(1, false), // inactive, ends in 1 hour
(2, true), // active, ends in 2 hours
(3, false), // inactive, ends in 3 hours
(4, true), // active, ends in 4 hours
(5, true), // active, ends in 5 hours
];
for (hours, is_active) in listing_configs {
let ends_at = base_time + Duration::hours(hours);
let inserted_listing = with_test_listing(&deps.deps, &seller, |listing| {
listing.base.ends_at = ends_at;
listing.base.is_active = is_active;
})
.await;
if is_active {
inserted_active_listings.push(inserted_listing);
}
}
let result = listing_dao
.find_next_ending_listing()
.await
.expect("Failed to query for next ending listing");
assert!(result.is_some(), "Expected to find an active listing");
let found_listing = result.unwrap();
// Should return the first active listing (ends in 2 hours), ignoring inactive ones
let expected_listing = &inserted_active_listings[0]; // The one ending in 2 hours
assert_eq!(found_listing.persisted.id, expected_listing.persisted.id);
assert!(found_listing.base.is_active);
}
}

View File

@@ -1,6 +1,27 @@
pub mod listing_dao;
pub mod user_dao;
mod bid_dao;
mod listing_dao;
mod user_dao;
// Re-export DAO structs for easy access
pub use bid_dao::BidDAO;
pub use listing_dao::ListingDAO;
pub use listing_dao::{ListingEventSender, ListingUpdatedEvent};
use sqlx::SqlitePool;
pub use user_dao::UserDAO;
#[derive(Clone)]
pub struct DAOs {
pub user: UserDAO,
pub listing: ListingDAO,
pub bid: BidDAO,
}
impl DAOs {
pub fn new(pool: SqlitePool, sender: ListingEventSender) -> Self {
Self {
user: UserDAO::new(pool.clone()),
listing: ListingDAO::new(pool.clone(), sender),
bid: BidDAO::new(pool),
}
}
}

View File

@@ -3,129 +3,193 @@
//! Provides encapsulated CRUD operations for User entities
use anyhow::Result;
use sqlx::SqlitePool;
use itertools::Itertools as _;
use sqlx::{sqlite::SqliteRow, FromRow, SqlitePool};
use crate::db::{
models::user::{NewUser, User},
TelegramUserId, UserRowId,
bind_fields::BindFields,
models::user::NewUser,
user::{PersistedUser, PersistedUserFields},
DbTelegramUserId, DbUserId,
};
/// Data Access Object for User operations
pub struct UserDAO;
#[derive(Clone)]
pub struct UserDAO(SqlitePool);
#[allow(unused)]
impl UserDAO {
/// Insert a new user into the database
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<User> {
let user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (telegram_id, username, display_name)
VALUES (?, ?, ?)
RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at
"#,
)
.bind(new_user.telegram_id)
.bind(&new_user.username)
.bind(&new_user.display_name)
.fetch_one(pool)
.await?;
pub fn new(pool: SqlitePool) -> Self {
Self(pool)
}
Ok(user)
/// Insert a new user into the database
pub async fn insert_user(&self, new_user: &NewUser) -> Result<PersistedUser> {
let binds = BindFields::default()
.push("telegram_id", &new_user.telegram_id)
.push("first_name", &new_user.first_name)
.push("last_name", &new_user.last_name)
.push("username", &new_user.username);
let query_str = format!(
r#"
INSERT INTO users ({})
VALUES ({})
RETURNING *
"#,
binds.bind_names().join(", "),
binds.bind_placeholders().join(", "),
);
let query = sqlx::query(&query_str);
let row = binds.bind_to_query(query).fetch_one(&self.0).await?;
Ok(FromRow::from_row(&row)?)
}
/// Find a user by their ID
pub async fn find_by_id(pool: &SqlitePool, user_id: UserRowId) -> Result<Option<User>> {
let user = sqlx::query_as::<_, User>(
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?"
pub async fn find_by_id(&self, user_id: DbUserId) -> Result<Option<PersistedUser>> {
Ok(
sqlx::query_as::<_, PersistedUser>("SELECT * FROM users WHERE id = ? ")
.bind(user_id)
.fetch_optional(&self.0)
.await?,
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(user)
}
/// Find a user by their Telegram ID
pub async fn find_by_telegram_id(
pool: &SqlitePool,
telegram_id: impl Into<TelegramUserId>,
) -> Result<Option<User>> {
&self,
telegram_id: impl Into<DbTelegramUserId>,
) -> Result<Option<PersistedUser>> {
let telegram_id = telegram_id.into();
let user = sqlx::query_as::<_, User>(
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?"
Ok(sqlx::query_as(
r#"
SELECT id, telegram_id, username, first_name, last_name, is_banned, created_at, updated_at
FROM users
WHERE telegram_id = ?
"#,
)
.bind(telegram_id)
.fetch_optional(pool)
.await?;
.fetch_optional(&self.0)
.await?)
}
pub async fn find_or_create_by_telegram_user(
&self,
user: teloxide::types::User,
) -> Result<PersistedUser> {
let binds = BindFields::default()
.push("telegram_id", &DbTelegramUserId::from(user.id))
.push("username", &user.username)
.push("first_name", &user.first_name)
.push("last_name", &user.last_name);
let query_str = format!(
r#"
INSERT INTO users ({})
VALUES ({})
ON CONFLICT (telegram_id) DO UPDATE SET
username = EXCLUDED.username,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name
RETURNING *
"#,
binds.bind_names().join(", "),
binds.bind_placeholders().join(", "),
);
let row = binds
.bind_to_query(sqlx::query(&query_str))
.fetch_one(&self.0)
.await?;
let user = FromRow::from_row(&row)?;
log::info!("load user from db: {user:?}");
Ok(user)
}
/// Update a user's information
pub async fn update_user(pool: &SqlitePool, user: &User) -> Result<User> {
let updated_user = sqlx::query_as::<_, User>(
pub async fn update_user(&self, user: &PersistedUser) -> Result<PersistedUser> {
let updated_user = sqlx::query_as::<_, PersistedUser>(
r#"
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 = ?
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.display_name)
.bind(&user.first_name)
.bind(&user.last_name)
.bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user.id)
.fetch_one(pool)
.bind(user.persisted.id)
.fetch_one(&self.0)
.await?;
Ok(updated_user)
}
/// Set a user's ban status
pub async fn set_ban_status(
pool: &SqlitePool,
user_id: UserRowId,
is_banned: bool,
) -> Result<()> {
pub async fn set_ban_status(&self, user_id: DbUserId, is_banned: bool) -> Result<()> {
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user_id)
.execute(pool)
.execute(&self.0)
.await?;
Ok(())
}
/// 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(&self, user_id: DbUserId) -> Result<()> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id)
.execute(pool)
.execute(&self.0)
.await?;
Ok(())
}
/// Get or create a user (find by telegram_id, create if not found)
pub async fn get_or_create_user(
pool: &SqlitePool,
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);
pub async fn where_in_ids(
&self,
ids: impl Iterator<Item = DbUserId>,
) -> Result<Vec<PersistedUser>> {
let mut builder =
sqlx::query_builder::QueryBuilder::new("SELECT * FROM users WHERE id IN (");
let mut count = 0;
for id in ids {
count += 1;
builder.push_bind(id);
}
if count == 0 {
return Ok(vec![]);
}
builder.push(")");
// Create new user if not found
let new_user = NewUser {
telegram_id,
username,
display_name,
};
let rows = builder.build().fetch_all(&self.0).await?;
Ok(rows
.into_iter()
.map(|row| PersistedUser::from_row(&row))
.collect::<Result<Vec<_>, _>>()?)
}
}
Self::insert_user(pool, &new_user).await
impl FromRow<'_, SqliteRow> for PersistedUser {
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
use sqlx::Row as _;
Ok(PersistedUser {
persisted: PersistedUserFields {
id: row.get("id"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
},
telegram_id: row.get("telegram_id"),
username: row.get("username"),
first_name: row.get("first_name"),
last_name: row.get("last_name"),
is_banned: row.get("is_banned"),
})
}
}
@@ -133,12 +197,11 @@ impl UserDAO {
mod tests {
use super::*;
use crate::db::models::user::NewUser;
use rstest::rstest;
use sqlx::SqlitePool;
use teloxide::types::UserId;
/// Create test database for UserDAO tests
async fn create_test_pool() -> SqlitePool {
async fn create_test_dao() -> UserDAO {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
@@ -149,118 +212,106 @@ mod tests {
.await
.expect("Failed to run database migrations");
pool
UserDAO::new(pool)
}
#[tokio::test]
async fn test_insert_and_find_user() {
let pool = create_test_pool().await;
let dao = create_test_dao().await;
let new_user = NewUser {
persisted: (),
telegram_id: 12345.into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
display_name: Some("Test User".to_string()),
is_banned: false,
};
// Insert user
let inserted_user = UserDAO::insert_user(&pool, &new_user)
let inserted_user = dao
.insert_user(&new_user)
.await
.expect("Failed to insert user");
assert_eq!(inserted_user.telegram_id, 12345.into());
assert_eq!(inserted_user.username, Some("testuser".to_string()));
assert_eq!(inserted_user.display_name, Some("Test User".to_string()));
assert_eq!(inserted_user.is_banned, false);
assert_eq!(inserted_user.first_name, "Test User".to_string());
assert!(!inserted_user.is_banned);
// Find by ID
let found_user = UserDAO::find_by_id(&pool, inserted_user.id)
let found_user = dao
.find_by_id(inserted_user.persisted.id)
.await
.expect("Failed to find user by id")
.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);
// Find by telegram ID
let found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(12345))
let found_by_telegram = dao
.find_by_telegram_id(UserId(12345))
.await
.expect("Failed to find user by telegram_id")
.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());
}
#[tokio::test]
async fn test_get_or_create_user() {
let pool = create_test_pool().await;
let dao = create_test_dao().await;
// First call should create the user
let user1 = UserDAO::get_or_create_user(
&pool,
67890.into(),
Some("newuser".to_string()),
Some("New User".to_string()),
)
.await
.expect("Failed to get or create user");
let user1 = dao
.find_or_create_by_telegram_user(teloxide::types::User {
id: UserId(67890),
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
.expect("Failed to get or create user");
assert_eq!(user1.telegram_id, 67890.into());
assert_eq!(user1.username, Some("newuser".to_string()));
// Second call should return the same user
let user2 = UserDAO::get_or_create_user(
&pool,
67890.into(),
Some("differentusername".to_string()), // This should be ignored
Some("Different Name".to_string()), // This should be ignored
)
.await
.expect("Failed to get or create user");
let user2 = dao
.find_or_create_by_telegram_user(teloxide::types::User {
id: UserId(67890),
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
.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
}
#[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]
async fn test_update_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let new_user = NewUser {
persisted: (),
telegram_id: 55555.into(),
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)
@@ -269,7 +320,7 @@ mod tests {
// Update user information
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;
let updated_user = UserDAO::update_user(&pool, &user)
@@ -277,18 +328,21 @@ mod tests {
.expect("Failed to update user");
assert_eq!(updated_user.username, Some("newname".to_string()));
assert_eq!(updated_user.display_name, Some("New Name".to_string()));
assert_eq!(updated_user.is_banned, true);
assert_eq!(updated_user.first_name, "New Name".to_string());
assert!(updated_user.is_banned);
}
#[tokio::test]
async fn test_delete_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let new_user = NewUser {
persisted: (),
telegram_id: 77777.into(),
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)
@@ -296,12 +350,12 @@ mod tests {
.expect("Failed to insert user");
// Delete user
UserDAO::delete_user(&pool, user.id)
UserDAO::delete_user(&pool, user.persisted.id)
.await
.expect("Failed to delete user");
// 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
.expect("Database operation should succeed");
@@ -310,10 +364,10 @@ mod tests {
#[tokio::test]
async fn test_find_nonexistent_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
// 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, DbUserId::new(99999))
.await
.expect("Database operation should succeed");
@@ -325,4 +379,145 @@ mod tests {
assert!(not_found_by_telegram.is_none());
}
mod upsert_tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(None, None, None)]
#[case(Some("new_user"), None, None)]
#[case(None, Some("New First"), None)]
#[case(None, None, Some("New Last"))]
#[case(Some(""), None, Some(""))]
#[tokio::test]
async fn test_upsert_updates_fields(
#[case] username: Option<&str>,
#[case] first_name: Option<&str>,
#[case] last_name: Option<&str>,
) {
let pool = create_test_dao().await;
let user_id = UserId(12345);
let initial = teloxide::types::User {
id: user_id,
is_bot: false,
first_name: "First".into(),
last_name: Some("Last".into()),
username: Some("user".into()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
let created = UserDAO::find_or_create_by_telegram_user(&pool, initial)
.await
.unwrap();
let mut updated = teloxide::types::User {
id: user_id,
is_bot: false,
first_name: "First".into(),
last_name: Some("Last".into()),
username: Some("user".into()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
if let Some(u) = username {
updated.username = if u.is_empty() { None } else { Some(u.into()) };
}
if let Some(f) = first_name {
updated.first_name = f.into();
}
if let Some(l) = last_name {
updated.last_name = if l.is_empty() { None } else { Some(l.into()) };
}
let result = UserDAO::find_or_create_by_telegram_user(&pool, updated.clone())
.await
.unwrap();
assert_eq!(created.persisted.id, result.persisted.id);
assert_eq!(result.username, updated.username);
assert_eq!(result.first_name, updated.first_name);
assert_eq!(result.last_name, updated.last_name);
}
#[tokio::test]
async fn test_multiple_users_separate() {
let pool = create_test_dao().await;
let user1 = teloxide::types::User {
id: UserId(111),
is_bot: false,
first_name: "One".into(),
last_name: None,
username: Some("one".into()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
let user2 = teloxide::types::User {
id: UserId(222),
is_bot: false,
first_name: "Two".into(),
last_name: Some("Last".into()),
username: None,
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
let p1 = UserDAO::find_or_create_by_telegram_user(&pool, user1)
.await
.unwrap();
let p2 = UserDAO::find_or_create_by_telegram_user(&pool, user2)
.await
.unwrap();
assert_ne!(p1.persisted.id, p2.persisted.id);
assert_eq!(p1.telegram_id, UserId(111).into());
assert_eq!(p2.telegram_id, UserId(222).into());
}
#[tokio::test]
async fn test_upsert_preserves_id_and_timestamps() {
let pool = create_test_dao().await;
let user = teloxide::types::User {
id: UserId(333),
is_bot: false,
first_name: "Original".into(),
last_name: None,
username: Some("orig".into()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
let created = UserDAO::find_or_create_by_telegram_user(&pool, user)
.await
.unwrap();
let updated_user = teloxide::types::User {
id: UserId(333),
is_bot: false,
first_name: "Original".into(),
last_name: None,
username: Some("updated".into()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
let updated = UserDAO::find_or_create_by_telegram_user(&pool, updated_user)
.await
.unwrap();
assert_eq!(created.persisted.id, updated.persisted.id);
assert_eq!(created.persisted.created_at, updated.persisted.created_at);
assert_eq!(updated.username, Some("updated".to_string()));
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod bind_fields;
pub mod dao;
pub mod models;
pub mod types;

View File

@@ -1,15 +1,26 @@
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use crate::db::MoneyAmount;
use crate::db::{DbBidId, DbListingId, DbProxyBidId, DbUserId, MoneyAmount};
pub type PersistedBid = Bid<PersistedBidFields>;
#[allow(unused)]
pub type NewBid = Bid<()>;
#[derive(Debug, Clone, PartialEq)]
#[allow(unused)]
pub struct PersistedBidFields {
pub id: DbBidId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Actual bids placed on listings
#[derive(Debug, Clone, PartialEq)]
#[allow(unused)]
#[derive(Debug, Clone, FromRow)]
pub struct Bid {
pub id: i64,
pub listing_id: i64,
pub buyer_id: i64,
pub struct Bid<P> {
pub persisted: P,
pub listing_id: DbListingId,
pub buyer_id: DbUserId,
pub bid_amount: MoneyAmount,
// For blind listings
@@ -20,20 +31,24 @@ pub struct Bid {
pub slot_number: Option<i32>, // For multi-slot listings
// Reference to proxy bid if auto-generated
pub proxy_bid_id: Option<i64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub proxy_bid_id: Option<DbProxyBidId>,
}
/// New bid data for insertion
#[allow(unused)]
#[derive(Debug, Clone)]
pub struct NewBid {
pub listing_id: i64,
pub buyer_id: i64,
pub bid_amount: MoneyAmount,
pub description: Option<String>,
pub slot_number: Option<i32>,
pub proxy_bid_id: Option<i64>,
impl<P> Bid<P> {
pub fn new_basic(
listing_id: DbListingId,
buyer_id: DbUserId,
bid_amount: MoneyAmount,
) -> NewBid {
NewBid {
persisted: (),
listing_id,
buyer_id,
bid_amount,
description: None,
is_cancelled: false,
slot_number: None,
proxy_bid_id: None,
}
}
}

View File

@@ -9,389 +9,213 @@
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
//! Database mapping is handled through `ListingRow` with conversion traits.
use super::listing_type::ListingType;
use crate::db::{ListingId, MoneyAmount, UserRowId};
use crate::db::{CurrencyType, DbListingId, DbUserId, ListingType, MoneyAmount};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
/// Main listing/auction entity
#[derive(Debug, Clone)]
#[allow(unused)]
pub struct Listing {
pub base: ListingBase,
pub fields: ListingFields,
}
pub type NewListing = Listing<()>;
pub type PersistedListing = Listing<PersistedListingFields>;
/// Common fields shared by all listing types
#[derive(Debug, Clone)]
#[allow(unused)]
pub struct ListingBase {
pub id: ListingId,
pub seller_id: UserRowId,
pub title: String,
pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields {
pub id: DbListingId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[allow(unused)]
pub enum ListingFields {
BasicAuction {
starting_bid: MoneyAmount,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
anti_snipe_minutes: Option<i32>,
},
MultiSlotAuction {
starting_bid: MoneyAmount,
buy_now_price: MoneyAmount,
min_increment: Option<MoneyAmount>,
slots_available: i32,
anti_snipe_minutes: i32,
},
FixedPriceListing {
buy_now_price: MoneyAmount,
slots_available: i32,
},
BlindAuction {
starting_bid: MoneyAmount,
},
/// Main listing/auction entity
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Listing<P: Debug + Clone> {
pub persisted: P,
pub base: ListingBase,
pub fields: 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,
pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields);
pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields);
impl<'a, P: Debug + Clone> From<&'a Listing<P>> for ListingBaseFields<'a> {
fn from(value: &'a Listing<P>) -> Self {
(&value.base, &value.fields)
}
}
impl<'a, P: Debug + Clone> From<&'a mut Listing<P>> for ListingBaseFieldsMut<'a> {
fn from(value: &'a mut Listing<P>) -> Self {
(&mut value.base, &mut value.fields)
}
}
/// Common fields shared by all listing types
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ListingBase {
pub seller_id: DbUserId,
pub title: String,
pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub currency_type: CurrencyType,
pub is_active: bool,
}
impl ListingBase {
#[cfg(test)]
pub fn with_fields(self, fields: ListingFields) -> NewListing {
Listing {
persisted: (),
base: self,
fields,
}
}
}
/// Fields specific to basic auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BasicAuctionFields {
pub starting_bid: MoneyAmount,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub anti_snipe_minutes: Option<i32>,
}
/// Fields specific to multi-slot auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultiSlotAuctionFields {
pub starting_bid: MoneyAmount,
pub buy_now_price: MoneyAmount,
pub min_increment: Option<MoneyAmount>,
pub slots_available: i32,
pub anti_snipe_minutes: i32,
}
/// Fields specific to fixed price listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixedPriceListingFields {
pub buy_now_price: MoneyAmount,
pub slots_available: i32,
}
/// Fields specific to blind auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlindAuctionFields {
pub starting_bid: MoneyAmount,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ListingFields {
BasicAuction(BasicAuctionFields),
MultiSlotAuction(MultiSlotAuctionFields),
FixedPriceListing(FixedPriceListingFields),
BlindAuction(BlindAuctionFields),
}
impl ListingFields {
pub fn listing_type(&self) -> ListingType {
match self {
ListingFields::BasicAuction(_) => ListingType::BasicAuction,
ListingFields::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
ListingFields::FixedPriceListing(_) => ListingType::FixedPriceListing,
ListingFields::BlindAuction(_) => ListingType::BlindAuction,
}
}
}
impl From<&ListingFields> for ListingType {
fn from(fields: &ListingFields) -> ListingType {
fields.listing_type()
}
}
#[cfg(test)]
mod tests {
use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId};
use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default};
use std::str::FromStr;
use super::*;
use chrono::{Duration, Utc};
use crate::db::{DbTelegramUserId, UserDAO};
use chrono::Duration;
use rstest::rstest;
use sqlx::SqlitePool;
/// Test utilities for creating an in-memory database with migrations
async fn create_test_pool() -> SqlitePool {
// Create an in-memory SQLite database for testing
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
// Run the migration
apply_test_migrations(&pool).await;
pool
}
/// Apply the database migrations for testing
async fn apply_test_migrations(pool: &SqlitePool) {
// Run the actual migrations from the migrations directory
sqlx::migrate!("./migrations")
.run(pool)
.await
.expect("Failed to run database migrations");
}
/// Create a test user using UserDAO and return their ID
async fn create_test_user(
pool: &SqlitePool,
telegram_id: TelegramUserId,
user_dao: &UserDAO,
telegram_id: DbTelegramUserId,
username: Option<&str>,
) -> UserRowId {
use crate::db::{models::user::NewUser, UserDAO};
) -> DbUserId {
use crate::db::models::user::NewUser;
let new_user = NewUser {
persisted: (),
telegram_id,
first_name: "Test User".to_string(),
last_name: None,
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 = user_dao
.insert_user(&new_user)
.await
.expect("Failed to create test user");
user.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"),
}
user.persisted.id
}
fn build_base_listing(
seller_id: UserRowId,
title: &str,
seller_id: DbUserId,
title: impl Into<String>,
description: Option<&str>,
) -> NewListingBase {
NewListingBase {
currency_type: CurrencyType,
) -> ListingBase {
ListingBase {
seller_id,
title: title.to_string(),
title: title.into(),
description: description.map(|s| s.to_string()),
currency_type,
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"),
ends_at: Utc::now() + Duration::days(3),
is_active: true,
}
}
#[rstest]
#[case("10.50", "100.00", "1.00")]
#[case("0.00", "50.00", "0.25")]
#[case("25.75", "999.99", "5.50")]
#[case(ListingFields::BlindAuction(BlindAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap() }))]
#[case(ListingFields::BasicAuction(BasicAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), min_increment: MoneyAmount::from_str("1.00").unwrap(), anti_snipe_minutes: Some(5) }))]
#[case(ListingFields::MultiSlotAuction(MultiSlotAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: MoneyAmount::from_str("100.00").unwrap(), min_increment: Some(MoneyAmount::from_str("1.00").unwrap()), slots_available: 10, anti_snipe_minutes: 5 }))]
#[case(ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 }))]
#[tokio::test]
async fn test_money_amount_precision_in_listings(
#[case] starting_bid_str: &str,
#[case] buy_now_price_str: &str,
#[case] min_increment_str: &str,
) {
let pool = create_test_pool().await;
let seller_id = create_test_user(&pool, 55555.into(), Some("precisionuser")).await;
async fn test_blind_auction_crud(#[case] fields: ListingFields) {
use crate::{
db::{ListingDAO, ListingEventSender},
test_utils::create_test_pool,
};
let listing = build_base_listing(seller_id, "Precision Test Auction", None)
.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),
);
let (tx, _) = ListingEventSender::channel();
let pool = create_test_pool().await;
let user_dao = UserDAO::new(pool.clone());
let listing_dao = ListingDAO::new(pool.clone(), tx);
let seller_id = create_test_user(&user_dao, 99999.into(), Some("testuser")).await;
let new_listing = build_base_listing(
seller_id,
"Test Auction",
Some("Test description"),
CurrencyType::Usd,
)
.with_fields(fields);
// Insert using DAO
let actual_id = ListingDAO::insert_listing(&pool, &listing)
let created_listing = listing_dao
.insert_listing(&new_listing)
.await
.expect("Failed to insert listing")
.base
.id;
.expect("Failed to insert listing");
// Fetch back from database using DAO
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
assert_eq!(created_listing.base, new_listing.base);
assert_eq!(created_listing.fields, new_listing.fields);
// Verify precision is maintained
match reconstructed_listing.fields {
ListingFields::BasicAuction {
starting_bid,
buy_now_price,
min_increment,
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"),
}
let read_listing = listing_dao
.find_by_id(created_listing.persisted.id)
.await
.expect("Failed to find listing")
.expect("Listing should exist");
assert_eq!(read_listing, created_listing);
}
}

View File

@@ -1,14 +1,42 @@
use std::fmt::Display;
/// 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(rename_all = "snake_case")]
pub enum ListingType {
/// Fixed price sale with no bidding
FixedPriceListing,
/// Traditional time-based auction with bidding
BasicAuction,
/// Auction with multiple winners/slots available
MultiSlotAuction,
/// Fixed price sale with no bidding
FixedPriceListing,
/// Blind auction where seller chooses winner
BlindAuction,
}
impl ListingType {
pub fn as_str(&self) -> &'static str {
match self {
ListingType::FixedPriceListing => "Fixed Price Listing",
ListingType::BasicAuction => "Basic Auction",
ListingType::MultiSlotAuction => "Multi-Slot Auction",
ListingType::BlindAuction => "Blind Auction",
}
}
pub fn emoji_str(&self) -> &'static str {
match self {
ListingType::FixedPriceListing => "🛍️",
ListingType::BasicAuction => "",
ListingType::MultiSlotAuction => "🎯",
ListingType::BlindAuction => "🎭",
}
}
}
impl Display for ListingType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}

View File

@@ -2,12 +2,9 @@ pub mod bid;
pub mod listing;
pub mod listing_media;
pub mod listing_type;
pub mod new_listing;
pub mod proxy_bid;
pub mod user;
pub mod user_settings;
// Re-export all types for easy access
pub use listing::*;
pub use listing_type::*;
pub use user::*;

View File

@@ -1,136 +0,0 @@
use crate::db::{ListingType, MoneyAmount, UserRowId};
use chrono::{DateTime, Utc};
/// New listing data for insertion
#[derive(Debug, Clone)]
pub struct NewListing {
pub base: NewListingBase,
pub fields: NewListingFields,
}
impl NewListing {
pub fn listing_type(&self) -> ListingType {
match &self.fields {
NewListingFields::BasicAuction { .. } => ListingType::BasicAuction,
NewListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
NewListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
NewListingFields::BlindAuction { .. } => ListingType::BlindAuction,
}
}
}
#[derive(Debug, Clone)]
pub struct NewListingBase {
pub seller_id: UserRowId,
pub title: String,
pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[allow(unused)]
pub enum NewListingFields {
BasicAuction {
starting_bid: MoneyAmount,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
anti_snipe_minutes: Option<i32>,
},
MultiSlotAuction {
starting_bid: MoneyAmount,
buy_now_price: MoneyAmount,
min_increment: Option<MoneyAmount>,
slots_available: i32,
anti_snipe_minutes: i32,
},
FixedPriceListing {
buy_now_price: MoneyAmount,
slots_available: i32,
},
BlindAuction {
starting_bid: MoneyAmount,
},
}
#[allow(unused)]
impl NewListingBase {
pub fn new(
seller_id: UserRowId,
title: String,
description: Option<String>,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
) -> Self {
Self {
seller_id,
title,
description,
starts_at,
ends_at,
}
}
/// Create a new basic auction listing
pub fn new_basic_auction(
self,
starting_bid: MoneyAmount,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
anti_snipe_minutes: Option<i32>,
) -> NewListing {
NewListing {
base: self,
fields: NewListingFields::BasicAuction {
starting_bid,
buy_now_price,
min_increment,
anti_snipe_minutes,
},
}
}
/// Create a new multi-slot auction listing
pub fn new_multi_slot_auction(
self,
starting_bid: MoneyAmount,
buy_now_price: MoneyAmount,
min_increment: Option<MoneyAmount>,
slots_available: i32,
anti_snipe_minutes: i32,
) -> NewListing {
NewListing {
base: self,
fields: NewListingFields::MultiSlotAuction {
starting_bid,
buy_now_price,
min_increment,
slots_available,
anti_snipe_minutes,
},
}
}
/// Create a new fixed price listing
pub fn new_fixed_price_listing(
self,
buy_now_price: MoneyAmount,
slots_available: i32,
) -> NewListing {
NewListing {
base: self,
fields: NewListingFields::FixedPriceListing {
buy_now_price,
slots_available,
},
}
}
/// Create a new blind auction listing
pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> NewListing {
NewListing {
base: self,
fields: NewListingFields::BlindAuction { starting_bid },
}
}
}

View File

@@ -1,26 +1,44 @@
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use std::fmt::Debug;
use crate::db::{TelegramUserId, UserRowId};
use crate::db::{DbTelegramUserId, DbUserId};
pub type PersistedUser = DbUser<PersistedUserFields>;
pub type NewUser = DbUser<()>;
/// Core user information
#[derive(Debug, Clone, FromRow)]
#[derive(Clone, FromRow, PartialEq)]
#[allow(unused)]
pub struct User {
pub id: UserRowId,
pub telegram_id: TelegramUserId,
pub struct DbUser<P: Debug + Clone> {
pub persisted: P,
pub telegram_id: DbTelegramUserId,
pub first_name: String,
pub last_name: 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,
}
impl Debug for DbUser<PersistedUserFields> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = if let Some(last_name) = self.last_name.as_deref() {
format!("{} {}", self.first_name, last_name)
} else {
self.first_name.clone()
};
let username = self.username.as_deref().unwrap_or("");
write!(
f,
"User(id: {} / {}, '{}' @{})",
self.persisted.id, self.telegram_id, name, username
)
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(unused)]
pub struct PersistedUserFields {
pub id: DbUserId,
pub created_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>,
}

View File

@@ -1,12 +1,17 @@
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
/// Currency types supported by the platform
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CurrencyType {
#[default]
Usd,
Usd, // United States Dollar
Cad, // Canadian Dollar
Gbp, // British Pound
Eur, // Euro
Jpy, // Japanese Yen
}
#[allow(unused)]
@@ -15,6 +20,10 @@ impl CurrencyType {
pub fn as_str(&self) -> &'static str {
match self {
CurrencyType::Usd => "USD",
CurrencyType::Cad => "CAD",
CurrencyType::Gbp => "GBP",
CurrencyType::Eur => "EUR",
CurrencyType::Jpy => "JPY",
}
}
@@ -22,13 +31,24 @@ impl CurrencyType {
pub fn symbol(&self) -> &'static str {
match self {
CurrencyType::Usd => "$",
CurrencyType::Cad => "$",
CurrencyType::Gbp => "£",
CurrencyType::Eur => "",
CurrencyType::Jpy => "¥",
}
}
}
/// Parse currency from string
pub fn from_str(s: &str) -> Result<Self, String> {
match s.to_uppercase().as_str() {
"USD" => Ok(CurrencyType::Usd),
impl TryFrom<&str> for CurrencyType {
type Error = String;
fn try_from(s: &str) -> Result<Self, String> {
match s.to_uppercase().trim() {
"USD" | "US" | "$" => Ok(CurrencyType::Usd),
"CAD" | "CA" => Ok(CurrencyType::Cad),
"GBP" | "GB" | "£" => Ok(CurrencyType::Gbp),
"EUR" | "EU" | "" => Ok(CurrencyType::Eur),
"JPY" | "JP" | "¥" => Ok(CurrencyType::Jpy),
_ => Err(format!("Unsupported currency: {s}")),
}
}
@@ -70,13 +90,14 @@ impl<'q> Encode<'q, Sqlite> for CurrencyType {
impl<'r> Decode<'r, Sqlite> for CurrencyType {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let currency_str = <&str as Decode<Sqlite>>::decode(value)?;
CurrencyType::from_str(currency_str).map_err(Into::into)
CurrencyType::try_from(currency_str).map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn test_currency_type_display() {
@@ -92,15 +113,20 @@ mod tests {
assert_eq!(default_currency, CurrencyType::Usd);
}
#[test]
fn test_currency_type_parsing() {
let parsed_currency = CurrencyType::from_str("usd").unwrap(); // Case insensitive
assert_eq!(parsed_currency, CurrencyType::Usd);
#[rstest]
#[case("usd", CurrencyType::Usd)]
#[case("USD", CurrencyType::Usd)]
#[case("USD", CurrencyType::Usd)]
#[case("CA", CurrencyType::Cad)]
fn test_currency_type_parsing(#[case] input: &str, #[case] expected: CurrencyType) {
let parsed_currency = CurrencyType::try_from(input).unwrap();
assert_eq!(parsed_currency, expected);
}
let parsed_upper = CurrencyType::from_str("USD").unwrap();
assert_eq!(parsed_upper, CurrencyType::Usd);
let invalid = CurrencyType::from_str("EUR");
#[rstest]
#[case("ASD")]
fn test_currency_type_parsing_invalid(#[case] input: &str) {
let invalid = CurrencyType::try_from(input);
assert!(invalid.is_err());
}
}

99
src/db/types/db_id.rs Normal file
View File

@@ -0,0 +1,99 @@
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
macro_rules! impl_db_id {
($id_name:ident, $id_type:ty) => {
#[doc = "Type-safe wrapper for "]
#[doc = stringify!($id_name)]
#[doc = " IDs"]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub struct $id_name($id_type);
impl $id_name {
/// Create a new ListingId from an i64
pub fn new(id: $id_type) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> $id_type {
self.0
}
}
impl From<$id_type> for $id_name {
fn from(id: $id_type) -> Self {
Self(id)
}
}
impl From<$id_name> for $id_type {
fn from(value: $id_name) -> Self {
value.0
}
}
impl fmt::Display for $id_name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for $id_name {
fn type_info() -> SqliteTypeInfo {
<$id_type as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<$id_type as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for $id_name {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<$id_type as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for $id_name {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <$id_type as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}
};
}
impl_db_id!(DbBidId, i64);
impl_db_id!(DbProxyBidId, i64);
impl_db_id!(DbListingId, i64);
impl_db_id!(DbUserId, i64);
impl_db_id!(DbTelegramUserId, i64);
impl From<teloxide::types::UserId> for DbTelegramUserId {
fn from(id: teloxide::types::UserId) -> Self {
Self(id.0 as i64)
}
}
impl From<DbTelegramUserId> for teloxide::types::UserId {
fn from(user_id: DbTelegramUserId) -> Self {
teloxide::types::UserId(user_id.0 as u64)
}
}
impl From<DbTelegramUserId> for teloxide::types::ChatId {
fn from(user_id: DbTelegramUserId) -> Self {
teloxide::types::ChatId(user_id.0)
}
}

View File

@@ -1,71 +0,0 @@
//! ListingId newtype for type-safe listing identification
//!
//! This newtype prevents accidentally mixing up listing IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for listing IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ListingId(i64);
impl ListingId {
/// Create a new ListingId from an i64
pub fn new(id: i64) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> i64 {
self.0
}
}
impl From<i64> for ListingId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<ListingId> for i64 {
fn from(listing_id: ListingId) -> Self {
listing_id.0
}
}
impl fmt::Display for ListingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for ListingId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for ListingId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for ListingId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}

View File

@@ -1,15 +1,13 @@
mod currency_type;
mod db_id;
mod listing_duration;
mod listing_id;
mod money;
mod money_amount;
mod telegram_user_id;
mod user_row_id;
// Re-export all types for easy access
#[allow(unused)]
pub use currency_type::*;
pub use db_id::*;
pub use listing_duration::*;
pub use listing_id::*;
pub use money::*;
pub use money_amount::*;
pub use telegram_user_id::*;
pub use user_row_id::*;

43
src/db/types/money.rs Normal file
View File

@@ -0,0 +1,43 @@
use crate::db::{CurrencyType, MoneyAmount};
use serde::{Deserialize, Serialize};
use std::fmt::Display;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money(CurrencyType, MoneyAmount);
impl Money {
pub fn new(currency_type: CurrencyType, money_amount: MoneyAmount) -> Self {
Self(currency_type, money_amount)
}
}
impl PartialOrd for Money {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.0 != other.0 {
return None;
}
self.1.partial_cmp(&other.1)
}
}
impl Display for Money {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.0.symbol(), self.1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("$100.00", CurrencyType::Usd, 100_00)]
#[case("¥100.00", CurrencyType::Jpy, 100_00)]
fn test_money_display(
#[case] expected: &str,
#[case] currency_type: CurrencyType,
#[case] amount_cents: i64,
) {
let amount = MoneyAmount::from_cents(amount_cents);
let money = Money::new(currency_type, amount);
assert_eq!(money.to_string(), expected);
}
}

View File

@@ -6,6 +6,8 @@ use sqlx::{
use std::ops::{Add, Sub};
use std::str::FromStr;
use crate::db::{CurrencyType, Money};
/// Newtype wrapper for monetary amounts stored as integer cents
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -23,12 +25,6 @@ impl MoneyAmount {
Self(cents)
}
/// Create a MoneyAmount from a string representation (e.g., "12.34")
pub fn from_str(s: &str) -> Result<Self, rust_decimal::Error> {
let decimal = Decimal::from_str(s)?;
Ok(Self::new(decimal))
}
/// Create a zero MoneyAmount
pub fn zero() -> Self {
Self(0)
@@ -43,6 +39,20 @@ impl MoneyAmount {
pub fn to_decimal(self) -> Decimal {
Decimal::new(self.0, 2) // 2 decimal places for cents
}
pub fn with_type(self, currency_type: CurrencyType) -> Money {
Money::new(currency_type, self)
}
}
impl FromStr for MoneyAmount {
type Err = rust_decimal::Error;
/// Create a MoneyAmount from a string representation (e.g., "12.34")
fn from_str(s: &str) -> Result<Self, Self::Err> {
let decimal = Decimal::from_str(s)?;
Ok(Self::new(decimal))
}
}
impl Default for MoneyAmount {
@@ -203,7 +213,7 @@ mod tests {
// Insert test data
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(amount)
.bind(CurrencyType::Usd)
.execute(&pool)
.await
@@ -245,7 +255,7 @@ mod tests {
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
.bind(MoneyAmount::from_str("100.00").unwrap())
.bind(CurrencyType::Usd)
.bind(&optional_amount)
.bind(optional_amount)
.execute(&pool)
.await
.expect("Failed to insert some optional amount");
@@ -290,7 +300,7 @@ mod tests {
// Insert into database
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(amount)
.bind(CurrencyType::Usd)
.execute(&pool)
.await
@@ -308,10 +318,7 @@ mod tests {
assert_eq!(
retrieved_amount.to_string(),
expected_str,
"Cent-level precision not correct for input: {} (expected: {}, got: {})",
input_str,
expected_str,
retrieved_amount.to_string()
"Cent-level precision not correct for input: {input_str} (expected: {expected_str}, got: {retrieved_amount})"
);
}
@@ -478,10 +485,8 @@ mod tests {
setup_test_data_for_queries(&pool).await;
let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
let query = format!(
"SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {} ?",
operator
);
let query =
format!("SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?");
let count_row = sqlx::query(&query)
.bind(threshold_amount)
@@ -492,8 +497,7 @@ mod tests {
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"Comparison {} {} failed",
operator, threshold
"Comparison {operator} {threshold} failed"
);
}
@@ -524,8 +528,7 @@ mod tests {
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"BETWEEN {} AND {} failed",
min_amount, max_amount
"BETWEEN {min_amount} AND {max_amount} failed"
);
}

View File

@@ -1,78 +0,0 @@
//! TelegramUserId
//! newtype for type-safe user identification
//!
//! This newtype prevents accidentally mixing up user IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TelegramUserId(teloxide::types::UserId);
impl TelegramUserId {
/// Create a new TelegramUserId
/// from an i64
pub fn new(id: teloxide::types::UserId) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> teloxide::types::UserId {
self.0
}
}
impl From<teloxide::types::UserId> for TelegramUserId {
fn from(id: teloxide::types::UserId) -> Self {
Self(id)
}
}
impl From<u64> for TelegramUserId {
fn from(id: u64) -> Self {
Self(teloxide::types::UserId(id))
}
}
impl From<TelegramUserId> for teloxide::types::UserId {
fn from(user_id: TelegramUserId) -> Self {
user_id.0
}
}
impl fmt::Display for TelegramUserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for TelegramUserId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for TelegramUserId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode(self.0 .0 as i64, args)
}
}
impl<'r> Decode<'r, Sqlite> for TelegramUserId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(teloxide::types::UserId(id as u64)))
}
}

View File

@@ -1,70 +0,0 @@
//! UserId newtype for type-safe user identification
//!
//! This newtype prevents accidentally mixing up user IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UserRowId(i64);
impl UserRowId {
/// Create a new UserId from an i64
pub fn new(id: i64) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> i64 {
self.0
}
}
impl From<i64> for UserRowId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<UserRowId> for i64 {
fn from(user_id: UserRowId) -> Self {
user_id.0
}
}
impl fmt::Display for UserRowId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for UserRowId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for UserRowId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for UserRowId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}

View File

@@ -1,3 +1,10 @@
use std::{collections::BTreeSet, ops::ControlFlow, sync::Arc};
use dptree::{
di::{Asyncify, Injectable},
from_fn_with_description, Handler, HandlerDescription, HandlerSignature, Type,
};
#[macro_export]
macro_rules! case {
// Basic variant matching without parameters
@@ -72,6 +79,66 @@ macro_rules! case {
};
}
pub trait MapTwo<'a, Output, Descr> {
#[must_use]
#[track_caller]
fn map2<Proj, NewType1, NewType2, Args>(self, proj: Proj) -> Handler<'a, Output, Descr>
where
Asyncify<Proj>: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a,
NewType1: Send + Sync + 'static,
NewType2: Send + Sync + 'static;
}
impl<'a, Output, Descr> MapTwo<'a, Output, Descr> for Handler<'a, Output, Descr>
where
Output: 'a,
Descr: HandlerDescription,
{
fn map2<Proj, NewType1, NewType2, Args>(self, proj: Proj) -> Handler<'a, Output, Descr>
where
Asyncify<Proj>: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a,
NewType1: Send + Sync + 'static,
NewType2: Send + Sync + 'static,
{
let proj = Arc::new(Asyncify(proj));
self.chain(from_fn_with_description(
Descr::map(),
move |container, cont| {
let proj = Arc::clone(&proj);
async move {
let proj = proj.inject(&container);
let (res1, res2) = proj().await;
std::mem::drop(proj);
let mut intermediate = container.clone();
intermediate.insert(res1);
intermediate.insert(res2);
match cont(intermediate).await {
ControlFlow::Continue(_) => ControlFlow::Continue(container),
ControlFlow::Break(result) => ControlFlow::Break(result),
}
}
},
HandlerSignature::Other {
obligations:
<Asyncify<Proj> as Injectable<(NewType1, NewType2), Args>>::obligations(),
guaranteed_outcomes: BTreeSet::from_iter(vec![
Type::of::<NewType1>(),
Type::of::<NewType2>(),
]),
conditional_outcomes: BTreeSet::new(),
continues: true,
},
))
}
}
pub fn identity<T>(t: T) -> T {
t
}
#[cfg(test)]
mod tests {
use std::ops::ControlFlow;
@@ -94,11 +161,11 @@ mod tests {
#[derive(Debug, Clone, PartialEq)]
enum InnerEnum {
InnerSimple,
InnerWithParam(&'static str),
InnerWithMultiple(&'static str, i32),
InnerWithStruct { field: &'static str },
InnerWithMultiStruct { field: &'static str, number: i32 },
Simple,
WithParam(&'static str),
WithMultiple(&'static str, i32),
WithStruct { field: &'static str },
WithMultiStruct { field: &'static str, number: i32 },
}
// Helper function for testing handlers with expected results
@@ -144,29 +211,29 @@ mod tests {
)]
// Single parameter extraction from nested enum
#[case::nested_single_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerWithParam("nested")),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::WithParam("nested")),
Some("nested")
)]
#[case::nested_single_wrong_inner_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::Simple),
None
)]
#[case::nested_single_wrong_outer_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::DefaultVariant,
None
)]
// Single field extraction from nested struct
#[case::struct_field_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_value" }),
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::WithStruct { field: "struct_value" }),
Some("struct_value")
)]
#[case::struct_field_no_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::Simple),
None
)]
#[tokio::test]
@@ -202,8 +269,8 @@ mod tests {
// Test cases for nested multiple parameter extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiple("nested", 123)), Some(("nested", 123)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithMultiple("nested", 123)), Some(("nested", 123)))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_nested_multiple_parameter_extraction(
@@ -211,7 +278,7 @@ mod tests {
#[case] expected_params: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiple(s, n))]
case![TestEnum::NestedVariant(InnerEnum::WithMultiple(s, n))]
.endpoint(|params: (&'static str, i32)| async move { params });
let input = deps![input_variant];
@@ -225,8 +292,8 @@ mod tests {
// Test cases for struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_field" }), Some("struct_field"))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithStruct { field: "struct_field" }), Some("struct_field"))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_struct_pattern_extraction(
@@ -234,10 +301,8 @@ mod tests {
#[case] expected_field: Option<&'static str>,
) {
let handler: Handler<'static, &str, DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct {
field
})]
.endpoint(|field: &'static str| async move { field });
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })]
.endpoint(|field: &'static str| async move { field });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
@@ -250,8 +315,8 @@ mod tests {
// Test cases for multi-field struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct { field: "multi_field", number: 42 }), Some(("multi_field", 42)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithMultiStruct { field: "multi_field", number: 42 }), Some(("multi_field", 42)))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_multi_struct_pattern_extraction(
@@ -259,7 +324,7 @@ mod tests {
#[case] expected_fields: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct {
case![TestEnum::NestedVariant(InnerEnum::WithMultiStruct {
field,
number
})]

36
src/handle_error.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::{wrap_endpoint, App, BotError, BotResult, WrappedAsyncFn};
use futures::future::BoxFuture;
pub async fn handle_error(app: App, error: BotError) -> BotResult {
log::error!("Error in handler: {error:?}");
match error {
BotError::UserVisibleError(message) => app.bot.send_html_message(message, None).await?,
BotError::InternalError(_) => {
app.bot
.send_html_message(
"An internal error occurred. Please try again later.".to_string(),
None,
)
.await?;
}
}
Ok(())
}
fn boxed_handle_error(app: App, error: BotError) -> BoxFuture<'static, BotResult> {
Box::pin(handle_error(app, error))
}
pub type ErrorHandlerWrapped<FnBase, FnBaseArgs> = WrappedAsyncFn<
FnBase,
fn(App, BotError) -> BoxFuture<'static, BotResult>,
BotError,
FnBaseArgs,
(App,),
>;
pub fn with_error_handler<FnBase, FnBaseArgs>(
handler: FnBase,
) -> ErrorHandlerWrapped<FnBase, FnBaseArgs> {
wrap_endpoint(handler, boxed_handle_error)
}

60
src/handler_utils.rs Normal file
View File

@@ -0,0 +1,60 @@
use log::warn;
use teloxide::types::Update;
use crate::{
db::{listing::PersistedListing, user::PersistedUser, DbListingId, ListingDAO, UserDAO},
MessageTarget,
};
pub async fn find_or_create_db_user_from_update(
user_dao: UserDAO,
update: Update,
) -> Option<PersistedUser> {
let user = update.from()?.clone();
find_or_create_db_user(user_dao, user).await
}
pub async fn find_or_create_db_user(
user_dao: UserDAO,
user: teloxide::types::User,
) -> Option<PersistedUser> {
match user_dao.find_or_create_by_telegram_user(user).await {
Ok(user) => {
log::debug!("loaded user from db: {user:?}");
Some(user)
}
Err(e) => {
log::error!("Error finding or creating user: {e}");
None
}
}
}
pub async fn find_listing_by_id(
listing_dao: ListingDAO,
listing_id: DbListingId,
) -> Option<PersistedListing> {
listing_dao.find_by_id(listing_id).await.unwrap_or(None)
}
pub fn update_into_message_target(update: Update) -> Option<MessageTarget> {
match update.kind {
teloxide::types::UpdateKind::Message(message) => Some(message.chat.into()),
teloxide::types::UpdateKind::InlineQuery(inline_query) => Some(inline_query.from.into()),
teloxide::types::UpdateKind::CallbackQuery(callback_query) => {
(&callback_query).try_into().ok()
}
_ => {
warn!("Received unexpected update kind: {update:?}");
None
}
}
}
// pub fn message_into_message_target(message: Message) -> MessageTarget {
// message.chat.id.into()
// }
// pub fn callback_query_into_message_target(callback_query: CallbackQuery) -> Option<MessageTarget> {
// (&callback_query).try_into().ok()
// }

View File

@@ -1,19 +1,19 @@
#[macro_export]
macro_rules! keyboard_buttons {
($vis:vis enum $name:ident {
$($variant:ident($text:literal, $callback_data:literal),)*
$($variant:ident($text:literal, $callback_data:literal)),* $(,)?
}) => {
keyboard_buttons! {
$vis enum $name {
[$($variant($text, $callback_data),)*]
[$($variant($text, $callback_data)),*]
}
}
};
($vis:vis enum $name:ident {
$([
$($variant:ident($text:literal, $callback_data:literal),)*
]),*
$($variant:ident($text:literal, $callback_data:literal)),* $(,)?
]),* $(,)?
}) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
$vis enum $name {
@@ -34,6 +34,34 @@ macro_rules! keyboard_buttons {
)*
markup
}
#[allow(unused)]
pub fn to_button(self) -> teloxide::types::InlineKeyboardButton {
match self {
$($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
}
}
#[allow(unused)]
pub fn to_switch_inline_query(self) -> teloxide::types::InlineKeyboardButton {
match self {
$($($name::$variant => teloxide::types::InlineKeyboardButton::switch_inline_query($text, $callback_data)),*),*
}
}
#[allow(unused)]
pub fn title(self) -> &'static str {
match self {
$($($name::$variant => $text),*),*
}
}
#[allow(unused)]
pub fn callback_data(self) -> &'static str {
match self {
$($($name::$variant => $callback_data),*),*
}
}
}
impl From<$name> for teloxide::types::InlineKeyboardButton {
fn from(value: $name) -> Self {
@@ -43,13 +71,13 @@ macro_rules! keyboard_buttons {
}
}
impl<'a> TryFrom<&'a str> for $name {
type Error = &'a str;
type Error = anyhow::Error;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
match value {
$($(
$callback_data => Ok(Self::$variant),
)*)*
_ => Err(value),
_ => anyhow::bail!("Unknown {name} button: {value}", name = stringify!($name)),
}
}
}

View File

@@ -0,0 +1,271 @@
use chrono::{DateTime, Duration, Utc};
use std::collections::VecDeque;
use tokio::sync::mpsc::Receiver;
use crate::{
db::{dao::ListingUpdatedEvent, listing::PersistedListing, DAOs},
*,
};
pub async fn task(
message_sender: BoxedMessageSender,
daos: DAOs,
mut listing_event: Receiver<ListingUpdatedEvent>,
) -> BotResult<()> {
const ERROR_WINDOW: Duration = Duration::seconds(3);
const COOLDOWN_DELAY: Duration = Duration::seconds(3);
let mut error_timestamps: VecDeque<DateTime<Utc>> = VecDeque::new();
loop {
match task_impl(&message_sender, &daos, &mut listing_event).await {
Ok(r) => return Ok(r),
Err(e) => {
log::error!("Error in listing expiry checker task: {e}");
let now = Utc::now();
error_timestamps.push_back(now);
// Remove errors older than the error window
while let Some(&front_time) = error_timestamps.front() {
if now - front_time > ERROR_WINDOW {
error_timestamps.pop_front();
} else {
break;
}
}
// If we have 2 or more errors within the window, apply cooldown
if error_timestamps.len() >= 2 {
log::warn!(
"Multiple errors ({}) within {} seconds, applying cooldown of {} seconds",
error_timestamps.len(),
ERROR_WINDOW.num_seconds(),
COOLDOWN_DELAY.num_seconds()
);
tokio::time::sleep(COOLDOWN_DELAY.to_std().map_err(BotError::internal)?).await;
}
continue;
}
}
}
}
async fn task_impl(
message_sender: &BoxedMessageSender,
daos: &DAOs,
listing_event: &mut Receiver<ListingUpdatedEvent>,
) -> BotResult<()> {
const MAX_CHECK_INTERVAL: Duration = Duration::seconds(10);
loop {
let check_again_in =
if let Some(listing) = daos.listing.find_next_ending_listing().await? {
if handle_listing_expiry(message_sender, daos, &listing).await? {
// if this listing was expired, immediately check again on the next listing
continue;
}
// wait until the listing ends, or a listing is updated
(Utc::now() - listing.base.ends_at).clamp(Duration::zero(), MAX_CHECK_INTERVAL)
} else {
MAX_CHECK_INTERVAL
}
.to_std()
.map_err(BotError::internal)?;
tokio::select! {
_ = listing_event.recv() => {
log::info!("listing updated, rechecking expiry");
continue;
}
_ = tokio::time::sleep(check_again_in) => {
log::info!("listing expired or timeout, rechecking expiry");
continue;
},
}
}
}
async fn handle_listing_expiry(
message_sender: &BoxedMessageSender,
daos: &DAOs,
listing: &PersistedListing,
) -> BotResult<bool> {
if listing.base.ends_at < Utc::now() {
let expired_ago = Utc::now() - listing.base.ends_at;
log::info!(
"listing expired: {listing_id} (expired {expired_ago} ago)",
listing_id = listing.persisted.id,
);
let listing = daos
.listing
.set_listing_is_active(listing.persisted.id, false)
.await?;
let seller = daos
.user
.find_by_id(listing.base.seller_id)
.await?
.ok_or(BotError::internal_str("Seller not found"))?;
let bids = daos.bid.bids_for_listing(listing.persisted.id).await?;
if let Some(bid) = bids.first() {
let buyer = daos
.user
.find_by_id(bid.buyer_id)
.await?
.ok_or(BotError::internal_str("Winning buyer not found"))?;
message_sender
.send_message(MessageType::UserHasWonListingForBuyer {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
bid: bid.clone(),
})
.await?;
message_sender
.send_message(MessageType::UserHasWonListingForSeller {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
bid: bid.clone(),
})
.await?;
// send message to all other buyers who did not win
for bid in bids.iter() {
if bid.buyer_id == buyer.persisted.id {
continue;
}
let buyer = daos
.user
.find_by_id(bid.buyer_id)
.await?
.ok_or(BotError::internal_str("Losing buyer not found"))?;
message_sender
.send_message(MessageType::UserHasLostListingForBuyer {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
})
.await?;
}
} else {
// there was no winner, send notification to the seller
message_sender
.send_message(MessageType::ListingExpiredWithNoWinnerForSeller {
listing: listing.clone(),
seller: seller.clone(),
})
.await?;
}
return Ok(true);
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::bid::NewBid;
use crate::db::MoneyAmount;
use crate::message_sender::MockMessageSender;
use crate::test_utils::*;
use mockall::predicate::function;
use std::str::FromStr;
#[tokio::test]
async fn test_listing_expiry() {
let Deps { deps, .. } = create_deps().await;
let seller = with_test_user(&deps, |user| {
user.telegram_id = 123.into();
user.username = Some("seller".to_string())
})
.await;
let losing_buyer = with_test_user(&deps, |user| {
user.telegram_id = 789.into();
user.username = Some("losing_buyer".to_string())
})
.await;
let winning_buyer = with_test_user(&deps, |user| {
user.telegram_id = 456.into();
user.username = Some("buyer".to_string())
})
.await;
let listing = with_test_listing(&deps, &seller, |listing| {
listing.base.ends_at = Utc::now() - Duration::seconds(1);
})
.await;
// the losing bid
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
losing_buyer.persisted.id,
MoneyAmount::from_str("50.00").unwrap(),
))
.await
.unwrap();
// the winning bid
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
winning_buyer.persisted.id,
MoneyAmount::from_str("100.00").unwrap(),
))
.await
.unwrap();
let winner_id = winning_buyer.persisted.id;
let loser_id = losing_buyer.persisted.id;
let mut message_sender = MockMessageSender::new();
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasWonListingForSeller { buyer, .. } => {
buyer.persisted.id == winner_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasWonListingForBuyer { buyer, .. } => {
buyer.persisted.id == winner_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasLostListingForBuyer { buyer, .. } => {
buyer.persisted.id == loser_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
let daos = (*deps.get::<DAOs>()).clone();
let message_sender: BoxedMessageSender = Box::new(message_sender);
handle_listing_expiry(&message_sender, &daos, &listing)
.await
.unwrap();
}
}

View File

@@ -1,28 +1,86 @@
mod bidding;
mod bot_message_sender;
mod bot_result;
mod commands;
mod config;
mod db;
mod dptree_utils;
mod handle_error;
mod handler_utils;
mod keyboard_utils;
mod listing_expiry_checker_task;
mod message;
mod message_sender;
mod message_target;
mod message_utils;
mod sqlite_storage;
mod start_command_data;
#[cfg(test)]
mod test_utils;
mod wrap_endpoint;
use std::sync::Arc;
use crate::bidding::{bidding_handler, BiddingState};
use crate::bot_message_sender::BotMessageSender;
use crate::commands::{
my_listings::{my_listings_handler, MyListingsState},
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
};
use crate::db::user::PersistedUser;
use crate::db::{DAOs, ListingEventSender};
use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
use crate::message::MessageType;
use crate::message_sender::BoxedMessageSender;
use crate::sqlite_storage::SqliteStorage;
use anyhow::Result;
use crate::start_command_data::StartCommandData;
use anyhow::{anyhow, Result};
pub use bot_result::*;
use chrono::{Duration, Utc};
use commands::*;
use config::Config;
use log::info;
pub use message_target::MessageTarget;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::types::Me;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub use wrap_endpoint::*;
pub type HandlerResult<T = ()> = anyhow::Result<T>;
pub type Handler = dptree::Handler<'static, HandlerResult, DpHandlerDescription>;
#[derive(Clone)]
pub struct App {
pub bot: Arc<BoxedMessageSender>,
pub daos: DAOs,
pub bot_username: String,
}
impl App {
pub fn new(bot: BoxedMessageSender, daos: DAOs, bot_username: String) -> Self {
Self {
bot: Arc::new(bot),
daos,
bot_username,
}
}
pub fn url_for_start_command(&self, command: StartCommandData) -> reqwest::Url {
format!(
"tg://resolve?domain={}&start={}",
self.bot_username,
command.encode_for_start_command()
)
.parse()
.unwrap()
}
pub async fn send_message(&self, message: MessageType) -> BotResult {
self.bot.send_message(message).await
}
}
#[derive(Debug, Clone)]
struct BotUsername(String);
/// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
@@ -62,12 +120,61 @@ pub enum Command {
enum DialogueRootState {
#[default]
Start,
MainMenu,
NewListing(NewListingState),
MyListings(MyListingsState),
Bidding(BiddingState),
}
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
pub fn main_handler() -> BotHandler {
dptree::entry()
.map(|daos: DAOs| daos.user.clone())
.map(|daos: DAOs| daos.listing.clone())
.map(|daos: DAOs| daos.bid.clone())
.filter_map_async(find_or_create_db_user_from_update)
.branch(
Update::filter_message()
.filter(filter_forwarded_from_pawctioneer_bot)
.endpoint(handle_forwarded_from_pawctioneer_bot),
)
.branch(my_listings_inline_handler())
.branch(
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(bidding_handler())
.branch(
Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.filter_command::<Command>()
.branch(
dptree::case![Command::Start]
.endpoint(with_error_handler(handle_start)),
)
.branch(
dptree::case![Command::Help].endpoint(with_error_handler(handle_help)),
)
.branch(
dptree::case![Command::MyBids]
.endpoint(with_error_handler(handle_my_bids)),
)
.branch(
dptree::case![Command::Settings]
.endpoint(with_error_handler(handle_settings)),
),
),
)
.branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler)))
}
#[tokio::main]
async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file
@@ -77,53 +184,108 @@ async fn main() -> Result<()> {
let db_pool = config.create_database_pool().await?;
info!("Starting Pawctioneer Bot...");
let bot = Bot::new(&config.telegram_token);
let bot = Box::new(Bot::new(&config.telegram_token));
// set up the listing expiry checker task
let (listing_expiry_checker_tx, listing_expiry_checker_rx) = ListingEventSender::channel();
let daos = DAOs::new(db_pool.clone(), listing_expiry_checker_tx);
tokio::spawn(listing_expiry_checker_task::task(
Box::new(BotMessageSender::new(*bot.clone(), None)),
daos.clone(),
listing_expiry_checker_rx,
));
// Set up the bot's command menu
setup_bot_commands(&bot).await?;
let handler_with_deps = dptree::entry()
.filter_map(
|bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| {
let target = update_into_message_target(update)?;
Some(App::new(
Box::new(BotMessageSender::new(*bot, Some(target))),
daos.clone(),
bot_username.0,
))
},
)
.chain(main_handler());
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
let bot_username = bot
.get_me()
.await?
.username
.as_ref()
.ok_or(anyhow!("Bot username not found"))?
.clone();
// Create dispatcher with dialogue system
Dispatcher::builder(
bot,
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<Command>()
.branch(dptree::case![Command::Start].endpoint(handle_start))
.branch(dptree::case![Command::Help].endpoint(handle_help))
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
),
Dispatcher::builder(bot, handler_with_deps)
.dependencies(dptree::deps![
dialog_storage,
daos,
BotUsername(bot_username)
])
.enable_ctrlc_handler()
.worker_queue_size(1)
.build()
.dispatch()
.await;
Ok(())
}
async fn unknown_message_handler(msg: Message) -> BotResult {
Err(BotError::UserVisibleError(format!(
"Unknown command: `{}`\n\n\
Try /help to see the list of commands.\
",
msg.text().unwrap_or("")
)))
}
fn filter_forwarded_from_pawctioneer_bot(message: Message, me: Me) -> bool {
if let Some(bot) = message.via_bot {
if bot.id == me.user.id {
return true;
}
}
false
}
async fn handle_forwarded_from_pawctioneer_bot(user: PersistedUser) -> BotResult {
info!("Received forwarded message from Pawctioneer Bot from {user:?}, ignoring...");
Ok(())
}
#[cfg(test)]
mod tests {
use mockall::predicate::{always, function};
use super::*;
use crate::message_sender::MockMessageSender;
use crate::test_utils::*;
#[tokio::test]
async fn test_main_handler() {
let mut message_sender = MockMessageSender::new();
message_sender
.expect_send_html_message()
.times(1)
.with(
function(|text: &String| text.contains("Available Commands:")),
always(),
)
.branch(Update::filter_message().endpoint(unknown_message_handler)),
)
.dependencies(dptree::deps![db_pool, dialog_storage])
.enable_ctrlc_handler()
.worker_queue_size(1)
.build()
.dispatch()
.await;
.returning(|_, _| Ok(()));
let Deps { deps, .. } = create_deps().await;
let mut deps = with_message_sender(deps, message_sender).await;
deps.insert(create_tele_update("/help"));
let handler = main_handler();
dptree::type_check(handler.sig(), &deps, &[]);
Ok(())
}
async fn unknown_message_handler(bot: Bot, msg: Message) -> HandlerResult {
bot.send_message(
msg.chat.id,
format!(
"
Unknown command: `{}`\n\n\
Try /help to see the list of commands.\
",
msg.text().unwrap_or("")
),
)
.await?;
Ok(())
let result = handler.dispatch(deps).await;
assert!(matches!(result, ControlFlow::Break(Ok(()))), "{:?}", result);
}
}

44
src/message/mod.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser};
#[derive(Debug, Clone, PartialEq)]
pub enum MessageType {
UserHasBeenOutbidForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
},
BidHasBeenPlacedForSeller {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
},
BidHasBeenConfirmedForBuyer {
listing: PersistedListing,
bid: PersistedBid,
},
BidInvalidListingExpired {
listing: PersistedListing,
buyer: PersistedUser,
},
UserHasWonListingForSeller {
listing: PersistedListing,
seller: PersistedUser,
buyer: PersistedUser,
bid: PersistedBid,
},
UserHasWonListingForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
},
UserHasLostListingForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
},
ListingExpiredWithNoWinnerForSeller {
listing: PersistedListing,
seller: PersistedUser,
},
}

50
src/message_sender.rs Normal file
View File

@@ -0,0 +1,50 @@
use crate::{message::MessageType, BotResult, MessageTarget};
use async_trait::async_trait;
use teloxide::types::{
CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me,
};
#[async_trait]
pub trait MessageSender {
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
async fn send_message(&self, message: MessageType) -> BotResult;
}
pub type BoxedMessageSender = Box<dyn MessageSender + Send + Sync>;
#[cfg(test)]
mockall::mock! {
pub MessageSender {}
impl Clone for MessageSender {
fn clone(&self) -> Self;
}
#[async_trait]
impl MessageSender for MessageSender {
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender;
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
async fn send_message(&self, message: MessageType) -> BotResult;
}
}

84
src/message_target.rs Normal file
View File

@@ -0,0 +1,84 @@
use std::fmt::Debug;
use teloxide::types::*;
use crate::db::user::DbUser;
#[derive(Debug, Clone)]
pub struct MessageTarget {
pub chat_id: ChatId,
pub message_id: Option<MessageId>,
}
impl MessageTarget {
pub fn only_chat_id(self) -> MessageTarget {
MessageTarget {
chat_id: self.chat_id,
message_id: None,
}
}
}
impl From<ChatId> for MessageTarget {
fn from(val: ChatId) -> Self {
MessageTarget {
chat_id: val,
message_id: None,
}
}
}
impl From<Chat> for MessageTarget {
fn from(val: Chat) -> Self {
MessageTarget {
chat_id: val.id,
message_id: None,
}
}
}
impl From<User> for MessageTarget {
fn from(val: User) -> Self {
MessageTarget {
chat_id: val.id.into(),
message_id: None,
}
}
}
impl From<(User, MessageId)> for MessageTarget {
fn from(val: (User, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id.into(),
message_id: Some(val.1),
}
}
}
impl From<(Chat, MessageId)> for MessageTarget {
fn from(val: (Chat, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id,
message_id: Some(val.1),
}
}
}
impl<P: Debug + Clone> From<DbUser<P>> for MessageTarget {
fn from(val: DbUser<P>) -> Self {
MessageTarget {
chat_id: val.telegram_id.into(),
message_id: None,
}
}
}
impl TryFrom<&CallbackQuery> for MessageTarget {
type Error = ();
fn try_from(val: &CallbackQuery) -> Result<Self, Self::Error> {
Ok(MessageTarget {
chat_id: val.from.id.into(),
message_id: val.message.as_ref().map(MaybeInaccessibleMessage::id),
})
}
}

View File

@@ -1,138 +1,11 @@
use crate::HandlerResult;
use anyhow::bail;
use crate::{db::user::PersistedUser, message_sender::BoxedMessageSender, BotResult};
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use num::One;
use std::fmt::Display;
use teloxide::{
payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
prelude::Requester as _,
types::{
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId,
ParseMode, User,
},
Bot,
};
#[derive(Debug, Clone, Copy)]
pub struct HandleAndId<'s> {
pub handle: Option<&'s str>,
pub id: ChatId,
}
impl<'s> Display for HandleAndId<'s> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.handle.unwrap_or("unknown"))?;
write!(f, " ({})", self.id.0)?;
Ok(())
}
}
impl<'s> HandleAndId<'s> {
pub fn from_chat(chat: &'s Chat) -> Self {
Self {
handle: chat.username(),
id: chat.id,
}
}
pub fn from_user(user: &'s User) -> Self {
Self {
handle: user.username.as_deref(),
id: user.id.into(),
}
}
}
impl<'s> From<&'s User> for HandleAndId<'s> {
fn from(val: &'s User) -> Self {
HandleAndId::from_user(val)
}
}
impl<'s> From<&'s Chat> for HandleAndId<'s> {
fn from(val: &'s Chat) -> Self {
HandleAndId::from_chat(val)
}
}
pub fn is_cancel(text: &str) -> bool {
text.eq_ignore_ascii_case("/cancel")
}
#[derive(Debug, Clone)]
pub struct MessageTarget {
pub chat_id: ChatId,
pub message_id: Option<MessageId>,
}
impl From<ChatId> for MessageTarget {
fn from(val: ChatId) -> Self {
MessageTarget {
chat_id: val,
message_id: None,
}
}
}
impl From<Chat> for MessageTarget {
fn from(val: Chat) -> Self {
MessageTarget {
chat_id: val.id,
message_id: None,
}
}
}
impl From<User> for MessageTarget {
fn from(val: User) -> Self {
MessageTarget {
chat_id: val.id.into(),
message_id: None,
}
}
}
impl From<(User, MessageId)> for MessageTarget {
fn from(val: (User, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id.into(),
message_id: Some(val.1),
}
}
}
impl From<(Chat, MessageId)> for MessageTarget {
fn from(val: (Chat, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id,
message_id: Some(val.1),
}
}
}
// Unified HTML message sending utility
pub async fn send_message(
bot: &Bot,
message_target: impl Into<MessageTarget>,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> HandlerResult {
let message_target = message_target.into();
if let Some(message_id) = message_target.message_id {
let mut message = bot
.edit_message_text(message_target.chat_id, message_id, text.as_ref())
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
} else {
let mut message = bot
.send_message(message_target.chat_id, text.as_ref())
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
}
Ok(())
}
use teloxide::types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup};
//
// ============================================================================
// KEYBOARD CREATION UTILITIES
// ============================================================================
@@ -142,44 +15,14 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK
InlineKeyboardMarkup::new([[InlineKeyboardButton::callback(text, callback_data)]])
}
// Create a keyboard with multiple buttons in a single row
pub fn create_single_row_keyboard(buttons: &[(&str, &str)]) -> InlineKeyboardMarkup {
let keyboard_buttons: Vec<InlineKeyboardButton> = buttons
.iter()
.map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data))
.collect();
InlineKeyboardMarkup::new([keyboard_buttons])
}
// Create a keyboard with multiple rows
pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();
for row in rows {
let buttons: Vec<InlineKeyboardButton> = row
.iter()
.map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data))
.collect();
keyboard = keyboard.append_row(buttons);
}
keyboard
}
// Extract callback data and answer callback query
pub async fn extract_callback_data(
bot: &Bot,
bot: &BoxedMessageSender,
callback_query: CallbackQuery,
) -> HandlerResult<(String, User, MessageId)> {
) -> BotResult<String> {
let data = match callback_query.data {
Some(data) => data,
None => bail!("Missing data in callback query"),
};
let from = callback_query.from.clone();
let message_id = if let Some(m) = callback_query.message {
m.id()
} else {
bail!("Missing message in callback query")
None => return Err(anyhow!("Missing data in callback query"))?,
};
// Answer the callback query to remove loading state
@@ -187,7 +30,7 @@ pub async fn extract_callback_data(
log::warn!("Failed to answer callback query: {e}");
}
Ok((data, from, message_id))
Ok(data)
}
pub fn pluralize<'a, N: One + PartialEq<N>>(
@@ -209,3 +52,21 @@ pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
) -> String {
format!("{} {}", count, pluralize(count, singular, plural))
}
pub fn format_datetime(dt: DateTime<Utc>) -> String {
dt.format("%b %d, %Y %H:%M UTC").to_string()
}
pub fn user_name_or_link(user: &PersistedUser) -> String {
let link = format!("tg://user?id={}", user.telegram_id);
let name = if let Some(last_name) = &user.last_name {
format!("{} {}", user.first_name, last_name)
} else {
user.first_name.clone()
};
if let Some(username) = &user.username {
format!("<a href='{link}'>@{username} ({name})</a>")
} else {
format!("<a href='{link}'>{name}</a>")
}
}

195
src/start_command_data.rs Normal file
View File

@@ -0,0 +1,195 @@
use base64::{prelude::BASE64_URL_SAFE, Engine};
use teloxide::types::{CallbackQuery, MediaKind, MessageKind, UpdateKind};
use crate::db::DbListingId;
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub enum StartCommandData {
PlaceBidOnListing(DbListingId),
ViewListingDetailsAsBuyer(DbListingId),
ViewListingBids(DbListingId),
}
const PLACE_BID_ON_LISTING: &str = "place_bid_on_listing";
const VIEW_LISTING_DETAILS_AS_BUYER: &str = "view_listing_details_as_buyer";
const VIEW_LISTING_BIDS: &str = "view_listing_bids";
impl From<StartCommandData> for String {
fn from(value: StartCommandData) -> Self {
match value {
StartCommandData::PlaceBidOnListing(listing_id) => {
format!("{PLACE_BID_ON_LISTING}:{listing_id}")
}
StartCommandData::ViewListingDetailsAsBuyer(listing_id) => {
format!("{VIEW_LISTING_DETAILS_AS_BUYER}:{listing_id}")
}
StartCommandData::ViewListingBids(listing_id) => {
format!("{VIEW_LISTING_BIDS}:{listing_id}")
}
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum StartCommandDataError {
MissingCommandName,
UnknownCommandName(String),
InvalidIdPart(String),
MissingIdPart,
}
impl TryFrom<String> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&String> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: &String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let mut parts = value.split(":").map(|s| s.trim());
let name = parts
.next()
.ok_or(StartCommandDataError::MissingCommandName)?;
let mut get_id_part = || -> Result<i64, StartCommandDataError> {
let id_part = parts.next().ok_or(StartCommandDataError::MissingIdPart)?;
id_part.parse::<i64>().map_err(|_| {
if id_part.is_empty() {
StartCommandDataError::MissingIdPart
} else {
StartCommandDataError::InvalidIdPart(id_part.to_string())
}
})
};
Ok(match name {
"" => return Err(StartCommandDataError::MissingCommandName),
PLACE_BID_ON_LISTING => Self::PlaceBidOnListing(DbListingId::new(get_id_part()?)),
VIEW_LISTING_DETAILS_AS_BUYER => {
Self::ViewListingDetailsAsBuyer(DbListingId::new(get_id_part()?))
}
VIEW_LISTING_BIDS => Self::ViewListingBids(DbListingId::new(get_id_part()?)),
_ => return Err(StartCommandDataError::UnknownCommandName(name.to_string())),
})
}
}
impl StartCommandData {
pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> {
let message = match update.kind {
UpdateKind::Message(message) => Some(message),
_ => None,
}?;
let message = match message.kind {
MessageKind::Common(message) => Some(message),
_ => None,
}?;
let message = match message.media_kind {
MediaKind::Text(media_text) => Some(media_text),
_ => None,
}?;
let message = message.text.strip_prefix("/start ")?;
let decoded = BASE64_URL_SAFE.decode(message).ok()?;
let decoded = String::from_utf8(decoded).ok()?;
StartCommandData::try_from(decoded.as_str()).ok()
}
pub fn get_from_callback_query(callback_query: CallbackQuery) -> Option<StartCommandData> {
let data = callback_query.data.as_ref()?;
StartCommandData::try_from(data.as_str()).ok()
}
pub fn encode_for_start_command(self) -> String {
let as_string: String = self.into();
BASE64_URL_SAFE.encode(as_string)
}
pub fn get_place_bid_on_listing_start_command(
command: StartCommandData,
) -> Option<DbListingId> {
if let StartCommandData::PlaceBidOnListing(listing_id) = command {
Some(listing_id)
} else {
None
}
}
pub fn get_view_listing_details_as_buyer_start_command(
command: StartCommandData,
) -> Option<DbListingId> {
if let StartCommandData::ViewListingDetailsAsBuyer(listing_id) = command {
Some(listing_id)
} else {
None
}
}
pub fn get_view_listing_bids_start_command(command: StartCommandData) -> Option<DbListingId> {
if let StartCommandData::ViewListingBids(listing_id) = command {
Some(listing_id)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use crate::{
db::DbListingId,
start_command_data::{StartCommandData, StartCommandDataError},
test_utils::create_tele_update,
};
#[test]
fn test_get_from_update() {
let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg==");
let command = StartCommandData::get_from_update(update);
assert_eq!(
command,
Some(StartCommandData::PlaceBidOnListing(DbListingId::new(2)))
);
}
#[test]
fn test_malformed_from_update() {
let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg");
let command = StartCommandData::get_from_update(update);
assert_eq!(command, None);
}
#[rstest]
#[case(StartCommandData::PlaceBidOnListing(DbListingId::new(1)))]
#[case(StartCommandData::ViewListingDetailsAsBuyer(DbListingId::new(2)))]
#[case(StartCommandData::ViewListingBids(DbListingId::new(3)))]
fn test_start_command_data(#[case] command: StartCommandData) {
let encoded: String = command.into();
let decoded = StartCommandData::try_from(encoded).unwrap();
assert_eq!(command, decoded);
}
#[rstest]
#[case("", StartCommandDataError::MissingCommandName)]
#[case("malformed", StartCommandDataError::UnknownCommandName("malformed".to_string()))]
#[case("place_bid_on_listing", StartCommandDataError::MissingIdPart)]
#[case("place_bid_on_listing:", StartCommandDataError::MissingIdPart)]
#[case("place_bid_on_listing:abc", StartCommandDataError::InvalidIdPart("abc".to_string()))]
fn test_malformed_start_command_data(
#[case] encoded: &str,
#[case] expected_error: StartCommandDataError,
) {
let decoded = StartCommandData::try_from(encoded);
assert_eq!(decoded, Err(expected_error));
}
}

View File

@@ -1,5 +1,29 @@
//! Test utilities including timestamp comparison macros
use chrono::{Duration, Utc};
use dptree::di::DependencyMap;
use sqlx::SqlitePool;
use std::{ops::Deref, str::FromStr, sync::Arc};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::types::*;
use tokio::sync::mpsc::Receiver;
use crate::{
db::{
listing::{
BasicAuctionFields, ListingBase,
ListingFields::{self, BasicAuction},
NewListing, PersistedListing,
},
user::{NewUser, PersistedUser},
CurrencyType, DAOs, ListingDAO, ListingEventSender, ListingUpdatedEvent, MoneyAmount,
UserDAO,
},
message_sender::MockMessageSender,
sqlite_storage::SqliteStorage,
App, DialogueRootState, RootDialogue,
};
/// Assert that two timestamps are approximately equal within a given epsilon tolerance.
///
/// This macro is useful for testing timestamps that may have small variations due to
@@ -87,54 +111,210 @@ 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"
);
pub async fn create_test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
// Run migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run database migrations");
pool
}
pub fn create_tele_user(user_fn: impl FnOnce(&mut User) -> ()) -> User {
let mut user = User {
id: UserId(1),
username: Some("username".to_string()),
first_name: "firstname".to_string(),
last_name: Some("lastname".to_string()),
is_bot: false,
language_code: Some("en".to_string()),
is_premium: false,
added_to_attachment_menu: false,
};
($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"
);
user_fn(&mut user);
user
}
pub fn create_tele_private_chat(user: &User) -> Chat {
Chat {
id: ChatId(1),
kind: ChatKind::Private(ChatPrivate {
username: user.username.clone(),
first_name: Some(user.first_name.clone()),
last_name: user.last_name.clone(),
}),
}
}
pub fn create_tele_update(message_text: &str) -> Update {
let user = create_tele_user(|user| user.username = Some("sender".to_string()));
let chat = create_tele_private_chat(&user);
Update {
id: UpdateId(1),
kind: UpdateKind::Message(Message {
id: MessageId(1),
thread_id: None,
from: Some(user),
sender_chat: None,
date: Utc::now(),
chat,
is_topic_message: false,
via_bot: None,
sender_business_bot: None,
kind: MessageKind::Common(MessageCommon {
media_kind: MediaKind::Text(MediaText {
text: message_text.to_string(),
entities: vec![],
link_preview_options: None,
}),
author_signature: None,
paid_star_count: None,
effect_id: None,
forward_origin: None,
reply_to_message: None,
external_reply: None,
quote: None,
reply_to_story: None,
sender_boost_count: None,
edit_date: None,
reply_markup: None,
is_automatic_forward: false,
has_protected_content: false,
is_from_offline: false,
business_connection_id: None,
}),
}),
}
}
pub fn create_tele_callback_query(callback_data: &str, from: User) -> CallbackQuery {
CallbackQuery {
id: CallbackQueryId("query_id".to_string()),
data: Some(callback_data.to_string()),
from: from.clone(),
message: None,
chat_instance: "chat_instance".to_string(),
inline_message_id: None,
game_short_name: None,
}
}
pub struct Deps {
pub deps: DependencyMap,
pub listing_event_receiver: Receiver<ListingUpdatedEvent>,
}
pub async fn create_deps() -> Deps {
let pool = create_test_pool().await;
let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap();
let (tx, rx) = ListingEventSender::channel();
let daos = DAOs::new(pool, tx);
let me_user = create_tele_user(|user| user.username = Some("me".to_string()));
let me = Me {
user: me_user,
can_join_groups: true,
can_read_all_group_messages: true,
supports_inline_queries: true,
can_connect_to_business: true,
has_main_web_app: true,
};
Deps {
deps: dptree::deps![dialog_storage, me, daos],
listing_event_receiver: rx,
}
}
pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap {
let storage = deps.get::<Arc<SqliteStorage<Json>>>().deref().clone();
let dialogue = RootDialogue::new(storage, user.telegram_id.into());
dialogue.update(DialogueRootState::Start).await.unwrap();
deps.insert(dialogue);
deps
}
pub async fn with_message_sender(
mut deps: DependencyMap,
mock_bot: MockMessageSender,
) -> DependencyMap {
let app = App::new(
Box::new(mock_bot),
deps.get::<DAOs>().deref().clone(),
"bot_username".to_string(),
);
deps.insert(app);
deps
}
pub async fn with_test_user(
deps: &DependencyMap,
user_fn: impl FnOnce(&mut NewUser) -> (),
) -> PersistedUser {
let user_dao = deps.get::<DAOs>().user.clone();
let mut new_user = NewUser {
persisted: (),
telegram_id: 12345.into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
is_banned: false,
};
user_fn(&mut new_user);
user_dao.insert_user(&new_user).await.unwrap()
}
pub async fn with_test_listing(
deps: &DependencyMap,
seller: &PersistedUser,
listing_fn: impl FnOnce(&mut NewListing) -> (),
) -> PersistedListing {
let listing_dao = deps.get::<DAOs>().listing.clone();
let mut new_listing = NewListing {
persisted: (),
base: ListingBase {
seller_id: seller.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
currency_type: CurrencyType::Usd,
is_active: true,
},
fields: ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("100.00").unwrap(),
buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()),
min_increment: MoneyAmount::from_str("1.00").unwrap(),
anti_snipe_minutes: Some(5),
}),
};
listing_fn(&mut new_listing);
listing_dao.insert_listing(&new_listing).await.unwrap()
}
pub async fn create_test_listing(dao: &ListingDAO, seller: &PersistedUser) -> PersistedListing {
dao.insert_listing(&NewListing {
persisted: (),
base: ListingBase {
seller_id: seller.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
currency_type: CurrencyType::Usd,
is_active: true,
},
fields: BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("100.00").unwrap(),
buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()),
min_increment: MoneyAmount::from_str("1.00").unwrap(),
anti_snipe_minutes: Some(5),
}),
})
.await
.unwrap()
}
#[cfg(test)]

141
src/wrap_endpoint.rs Normal file
View File

@@ -0,0 +1,141 @@
use dptree::di::{CompiledFn, DependencyMap, Injectable};
use dptree::Type;
use std::sync::Arc;
use std::{collections::BTreeSet, future::Future, marker::PhantomData};
pub struct WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
fn_base: FnBase,
fn_error: FnError,
_error_type: PhantomData<ErrorType>,
_fn_base_args: PhantomData<FnBaseArgs>,
_fn_error_args: PhantomData<FnErrorArgs>,
}
impl<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>
WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>
{
fn new(fn_base: FnBase, fn_error: FnError) -> Self {
Self {
fn_base,
fn_error,
_error_type: PhantomData,
_fn_base_args: PhantomData,
_fn_error_args: PhantomData,
}
}
}
macro_rules! generate_wrapped {
([$($base_generic:ident),*], [$($error_generic:ident),*]) => {
impl<FnBase, FnError, FnBaseFut, FnErrorFut, T, ErrorType, $($base_generic,)* $($error_generic,)*>
Injectable<Result<T, ErrorType>, ($($base_generic,)* $($error_generic,)*)>
for WrappedAsyncFn<FnBase, FnError, ErrorType, ($($base_generic,)*), ($($error_generic,)*)>
where
FnBase: Fn($($base_generic,)*) -> FnBaseFut + Send + Sync + 'static,
FnBaseFut: Future<Output = Result<T, ErrorType>> + Send + 'static,
FnError: Fn($($error_generic,)* ErrorType) -> FnErrorFut + Send + Sync + 'static,
FnErrorFut: Future<Output = Result<T, ErrorType>> + Send + 'static,
T: 'static + Send + Sync,
ErrorType: 'static + Send + Sync,
$($base_generic: Clone + Send + Sync + 'static,)*
$($error_generic: Clone + Send + Sync + 'static,)*
{
#[allow(non_snake_case)]
#[allow(unused_variables)]
fn inject<'a>(&'a self, container: &'a DependencyMap) -> CompiledFn<'a, Result<T, ErrorType>> {
Arc::new(move || {
$(let $base_generic = std::borrow::Borrow::<$base_generic>::borrow(&container.get()).clone();)*
let base_fut = (self.fn_base)($($base_generic),*);
Box::pin(async move {
match base_fut.await {
Ok(t) => Ok(t),
Err(err) => {
$(let $error_generic = std::borrow::Borrow::<$error_generic>::borrow(&container.get()).clone();)*
(self.fn_error)($($error_generic,)* err).await
}
}
})
})
}
fn input_types() -> BTreeSet<Type> {
BTreeSet::from_iter(vec![
$(Type::of::<$base_generic>(),)*
$(Type::of::<$error_generic>(),)*
])
}
}
};
}
macro_rules! generate_wrapped_all {
// Entry: two lists (base generics, error generics)
([$($base:ident),*], [$($err:ident),*]) => {
generate_wrapped_all!(@recurse_base [$($base),*] [$($err),*]);
};
// Recurse over base prefixes (from full list down to empty)
(@recurse_base [] [$($err:ident),*]) => {
generate_wrapped_all!(@recurse_err [] [$($err),*]);
};
(@recurse_base [$head:ident $(, $tail:ident)*] [$($err:ident),*]) => {
generate_wrapped_all!(@recurse_err [$head $(, $tail)*] [$($err),*]);
generate_wrapped_all!(@recurse_base [$( $tail ),*] [$($err),*]);
};
// For a fixed base prefix, recurse over error prefixes (from full list down to empty)
(@recurse_err [$($base_current:ident),*] []) => {
generate_wrapped!([$($base_current),*], []);
};
(@recurse_err [$($base_current:ident),*] [$ehead:ident $(, $etail:ident)*]) => {
generate_wrapped!([$($base_current),*], [$ehead $(, $etail)*]);
generate_wrapped_all!(@recurse_err [$($base_current),*] [$( $etail ),*]);
};
}
// Generate cartesian product of prefixes up to 12 base generics and 2 error generics
generate_wrapped_all!(
[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12],
[E1, E2]
);
pub fn wrap_endpoint<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>(
fn_base: FnBase,
error_fn: FnError,
) -> WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
WrappedAsyncFn::new(fn_base, error_fn)
}
#[cfg(test)]
mod tests {
use crate::wrap_endpoint;
use dptree::{deps, Handler};
use std::ops::ControlFlow;
use teloxide::dispatching::DpHandlerDescription;
#[derive(Debug, PartialEq, Eq)]
enum MyError {
ErrorValue,
}
type MyHandler = Handler<'static, Result<&'static str, MyError>, DpHandlerDescription>;
#[tokio::test]
async fn test_handler_errors() {
let service: MyHandler = dptree::entry().endpoint(wrap_endpoint(
async |_: &'static str| Err(MyError::ErrorValue),
async |_: MyError| Ok("caught"),
));
let result = service.dispatch(deps!["test"]).await;
assert_eq!(result, ControlFlow::Break(Ok("caught")));
}
#[tokio::test]
async fn test_handler_ok() {
let service: MyHandler = dptree::entry().endpoint(wrap_endpoint(
async |arg1: &'static str| Ok(arg1),
async |error: MyError| Err(error),
));
let result = service.dispatch(deps!["test"]).await;
assert_eq!(result, ControlFlow::Break(Ok("test")));
}
}