From 3a7d0a6905d7103de6dfe01b37d762623bf47978 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Thu, 28 Aug 2025 01:15:40 +0000 Subject: [PATCH] refactor: organize DAOs into dedicated db/dao/ directory - Move listing_dao.rs and user_dao.rs to src/db/dao/ - Create dao/mod.rs with proper re-exports for ListingDAO and UserDAO - Update import paths in DAO files to work from new location - Update db/mod.rs to import from new dao module - All tests still pass - no functionality changes --- migrations/20240827001_initial_schema.sql | 38 +-- src/db/dao/listing_dao.rs | 233 ++++++++++++++++ src/db/dao/mod.rs | 6 + src/db/dao/user_dao.rs | 323 ++++++++++++++++++++++ src/db/mod.rs | 6 +- src/db/models/mod.rs | 1 + src/db/models/new_listing.rs | 134 +++++++++ src/db/models/user.rs | 9 +- src/db/money_amount.rs | 3 +- src/db/telegram_user_id.rs | 79 ++++++ src/db/users.rs | 101 ------- 11 files changed, 806 insertions(+), 127 deletions(-) create mode 100644 src/db/dao/listing_dao.rs create mode 100644 src/db/dao/mod.rs create mode 100644 src/db/dao/user_dao.rs create mode 100644 src/db/models/new_listing.rs create mode 100644 src/db/telegram_user_id.rs delete mode 100644 src/db/users.rs diff --git a/migrations/20240827001_initial_schema.sql b/migrations/20240827001_initial_schema.sql index 00b03fc..4ec81f1 100644 --- a/migrations/20240827001_initial_schema.sql +++ b/migrations/20240827001_initial_schema.sql @@ -10,9 +10,9 @@ CREATE TABLE users ( telegram_id INTEGER UNIQUE NOT NULL, username TEXT, display_name TEXT, - is_banned BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + is_banned INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) STRICT; -- Main listing table (handles all listing types) @@ -32,12 +32,12 @@ CREATE TABLE listings ( slots_available INTEGER DEFAULT 1, -- Timing - starts_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ends_at TIMESTAMP NOT NULL, + starts_at TEXT DEFAULT CURRENT_TIMESTAMP, + ends_at TEXT NOT NULL, anti_snipe_minutes INTEGER DEFAULT 5, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (seller_id) REFERENCES users(id) ) STRICT; @@ -47,10 +47,10 @@ CREATE TABLE proxy_bids ( listing_id INTEGER NOT NULL, buyer_id INTEGER NOT NULL, max_amount INTEGER NOT NULL, -- stored as cents - is_active BOOLEAN DEFAULT TRUE, + is_active INTEGER DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (listing_id) REFERENCES listings(id), FOREIGN KEY (buyer_id) REFERENCES users(id), @@ -68,14 +68,14 @@ CREATE TABLE bids ( description TEXT, -- Status - is_cancelled BOOLEAN DEFAULT FALSE, + is_cancelled INTEGER DEFAULT 0, slot_number INTEGER, -- For multi-slot listings -- NULL = manual bid, NOT NULL = generated from proxy proxy_bid_id INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (listing_id) REFERENCES listings(id), FOREIGN KEY (buyer_id) REFERENCES users(id), FOREIGN KEY (proxy_bid_id) REFERENCES proxy_bids(id) @@ -88,8 +88,8 @@ CREATE TABLE listing_medias ( telegram_file_id TEXT NOT NULL, media_type TEXT NOT NULL, -- 'photo', 'video' position INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (listing_id) REFERENCES listings(id) ) STRICT; @@ -97,10 +97,10 @@ CREATE TABLE listing_medias ( CREATE TABLE user_settings ( user_id INTEGER PRIMARY KEY, language_code TEXT DEFAULT 'en', - notify_outbid BOOLEAN DEFAULT TRUE, - notify_won BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + notify_outbid INTEGER DEFAULT 1, + notify_won INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) STRICT; diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs new file mode 100644 index 0000000..79a9c5d --- /dev/null +++ b/src/db/dao/listing_dao.rs @@ -0,0 +1,233 @@ +//! Listing Data Access Object (DAO) +//! +//! Provides encapsulated CRUD operations for Listing entities + +use anyhow::Result; +use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; + +use crate::db::{ + new_listing::{NewListing, NewListingFields}, + ListingBase, ListingFields, +}; + +use super::super::{ + listing_id::ListingId, models::listing::Listing, models::listing_type::ListingType, + user_id::UserId, +}; + +/// Data Access Object for Listing operations +pub struct ListingDAO; + +impl ListingDAO { + /// Insert a new listing into the database + pub async fn insert_listing(pool: &SqlitePool, new_listing: &NewListing) -> Result { + let listing_type = new_listing.listing_type(); + let base = &new_listing.base; + let fields = &new_listing.fields; + + 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 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 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 = row.fetch_one(pool).await?; + Self::row_to_listing(row) + } + + /// Find a listing by its ID + pub async fn find_by_id(pool: &SqlitePool, listing_id: ListingId) -> Result> { + let result = sqlx::query("SELECT * FROM listings WHERE id = ?") + .bind(listing_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(Self::row_to_listing).transpose()?) + } + + /// Find all listings by a seller + pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result> { + let rows = + sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") + .bind(seller_id) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(Self::row_to_listing) + .collect::>>()?) + } + + /// Find all listings of a specific type + pub async fn find_by_type( + pool: &SqlitePool, + listing_type: ListingType, + ) -> Result> { + let rows = + sqlx::query("SELECT * FROM listings WHERE listing_type = ? ORDER BY created_at DESC") + .bind(listing_type) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(Self::row_to_listing) + .collect::>>()?) + } + + /// Find active listings (not ended yet) + pub async fn find_active_listings(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT * FROM listings WHERE ends_at > CURRENT_TIMESTAMP ORDER BY ends_at ASC", + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(Self::row_to_listing) + .collect::>>()?) + } + + /// Delete a listing + pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> { + sqlx::query("DELETE FROM listings WHERE id = ?") + .bind(listing_id) + .execute(pool) + .await?; + + Ok(()) + } + + /// Count total listings + pub async fn count_listings(pool: &SqlitePool) -> Result { + let row = sqlx::query("SELECT COUNT(*) as count FROM listings") + .fetch_one(pool) + .await?; + + Ok(row.get("count")) + } + + /// Count listings by seller + pub async fn count_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result { + let row = sqlx::query("SELECT COUNT(*) as count FROM listings WHERE seller_id = ?") + .bind(seller_id) + .fetch_one(pool) + .await?; + + Ok(row.get("count")) + } + + fn row_to_listing(row: SqliteRow) -> Result { + 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"), + 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"), + }, + }; + Ok(Listing { base, fields }) + } +} diff --git a/src/db/dao/mod.rs b/src/db/dao/mod.rs new file mode 100644 index 0000000..a9d6981 --- /dev/null +++ b/src/db/dao/mod.rs @@ -0,0 +1,6 @@ +pub mod listing_dao; +pub mod user_dao; + +// Re-export DAO structs for easy access +pub use listing_dao::ListingDAO; +pub use user_dao::UserDAO; diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs new file mode 100644 index 0000000..795ab29 --- /dev/null +++ b/src/db/dao/user_dao.rs @@ -0,0 +1,323 @@ +//! User Data Access Object (DAO) +//! +//! Provides encapsulated CRUD operations for User entities + +use anyhow::Result; +use sqlx::SqlitePool; + +use crate::db::TelegramUserId; + +use super::super::{ + models::user::{NewUser, User}, + user_id::UserId, +}; + +/// Data Access Object for User operations +pub struct UserDAO; + +impl UserDAO { + /// Insert a new user into the database + pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result { + 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?; + + Ok(user) + } + + /// Find a user by their ID + pub async fn find_by_id(pool: &SqlitePool, user_id: UserId) -> Result> { + let user = sqlx::query_as::<_, User>( + "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?" + ) + .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: TelegramUserId, + ) -> Result> { + let user = sqlx::query_as::<_, User>( + "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?" + ) + .bind(telegram_id) + .fetch_optional(pool) + .await?; + + Ok(user) + } + + /// Update a user's information + pub async fn update_user(pool: &SqlitePool, user: &User) -> Result { + let updated_user = sqlx::query_as::<_, User>( + r#" + UPDATE users + SET username = ?, display_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at + "#, + ) + .bind(&user.username) + .bind(&user.display_name) + .bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite + .bind(user.id) + .fetch_one(pool) + .await?; + + Ok(updated_user) + } + + /// Set a user's ban status + pub async fn set_ban_status(pool: &SqlitePool, user_id: UserId, 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) + .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: UserId) -> Result<()> { + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(user_id) + .execute(pool) + .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, + display_name: Option, + ) -> Result { + // Try to find existing user first + if let Some(existing_user) = Self::find_by_telegram_id(pool, telegram_id).await? { + return Ok(existing_user); + } + + // Create new user if not found + let new_user = NewUser { + telegram_id, + username, + display_name, + }; + + Self::insert_user(pool, &new_user).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::models::user::{NewUser, User}; + use rstest::rstest; + use sqlx::SqlitePool; + + /// Create test database for UserDAO tests + 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 + } + + #[tokio::test] + async fn test_insert_and_find_user() { + let pool = create_test_pool().await; + + let new_user = NewUser { + telegram_id: 12345.into(), + username: Some("testuser".to_string()), + display_name: Some("Test User".to_string()), + }; + + // Insert user + let inserted_user = UserDAO::insert_user(&pool, &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); + + // Find by ID + let found_user = UserDAO::find_by_id(&pool, inserted_user.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.telegram_id, inserted_user.telegram_id); + + // Find by telegram ID + let found_by_telegram = UserDAO::find_by_telegram_id(&pool, 12345.into()) + .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.telegram_id, 12345.into()); + } + + #[tokio::test] + async fn test_get_or_create_user() { + let pool = create_test_pool().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"); + + 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"); + + assert_eq!(user1.id, user2.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 new_user = NewUser { + telegram_id: 55555.into(), + username: Some("oldname".to_string()), + display_name: Some("Old Name".to_string()), + }; + + let mut user = UserDAO::insert_user(&pool, &new_user) + .await + .expect("Failed to insert user"); + + // Update user information + user.username = Some("newname".to_string()); + user.display_name = Some("New Name".to_string()); + user.is_banned = true; + + let updated_user = UserDAO::update_user(&pool, &user) + .await + .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); + } + + #[tokio::test] + async fn test_delete_user() { + let pool = create_test_pool().await; + + let new_user = NewUser { + telegram_id: 77777.into(), + username: Some("deleteme".to_string()), + display_name: Some("Delete Me".to_string()), + }; + + let user = UserDAO::insert_user(&pool, &new_user) + .await + .expect("Failed to insert user"); + + // Delete user + UserDAO::delete_user(&pool, user.id) + .await + .expect("Failed to delete user"); + + // Verify user is gone + let not_found = UserDAO::find_by_id(&pool, user.id) + .await + .expect("Database operation should succeed"); + + assert!(not_found.is_none()); + } + + #[tokio::test] + async fn test_find_nonexistent_user() { + let pool = create_test_pool().await; + + // Try to find a user that doesn't exist + let not_found = UserDAO::find_by_id(&pool, UserId::new(99999)) + .await + .expect("Database operation should succeed"); + + assert!(not_found.is_none()); + + let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, 88888.into()) + .await + .expect("Database operation should succeed"); + + assert!(not_found_by_telegram.is_none()); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 9cdfe2f..177cdf6 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,14 +1,16 @@ pub mod currency_type; +pub mod dao; pub mod listing_id; pub mod models; pub mod money_amount; +pub mod telegram_user_id; pub mod user_id; -pub mod users; // Re-export all models for easy access pub use currency_type::*; +pub use dao::*; pub use listing_id::*; pub use models::*; pub use money_amount::*; +pub use telegram_user_id::*; pub use user_id::*; -pub use users::UserRepository; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index bdcc874..ab1adea 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -2,6 +2,7 @@ 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; diff --git a/src/db/models/new_listing.rs b/src/db/models/new_listing.rs new file mode 100644 index 0000000..81bfe74 --- /dev/null +++ b/src/db/models/new_listing.rs @@ -0,0 +1,134 @@ +use crate::db::{ListingType, MoneyAmount, UserId}; +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: UserId, + pub title: String, + pub description: Option, + pub starts_at: DateTime, + pub ends_at: DateTime, +} + +#[derive(Debug, Clone)] +pub enum NewListingFields { + BasicAuction { + starting_bid: MoneyAmount, + buy_now_price: Option, + min_increment: MoneyAmount, + anti_snipe_minutes: Option, + }, + MultiSlotAuction { + starting_bid: MoneyAmount, + buy_now_price: MoneyAmount, + min_increment: Option, + slots_available: i32, + anti_snipe_minutes: i32, + }, + FixedPriceListing { + buy_now_price: MoneyAmount, + slots_available: i32, + }, + BlindAuction { + starting_bid: MoneyAmount, + }, +} + +impl NewListingBase { + pub fn new( + seller_id: UserId, + title: String, + description: Option, + starts_at: DateTime, + ends_at: DateTime, + ) -> 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, + min_increment: MoneyAmount, + anti_snipe_minutes: Option, + ) -> 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, + 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 }, + } + } +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs index a065177..650647c 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -2,13 +2,16 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use crate::db::{TelegramUserId, UserId}; + /// Core user information #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct User { - pub id: i64, - pub telegram_id: i64, + pub id: UserId, + pub telegram_id: TelegramUserId, pub username: Option, pub display_name: Option, + // SQLite stores booleans as INTEGER (0/1), sqlx FromRow handles the conversion automatically pub is_banned: bool, pub created_at: DateTime, pub updated_at: DateTime, @@ -17,7 +20,7 @@ pub struct User { /// New user data for insertion #[derive(Debug, Clone)] pub struct NewUser { - pub telegram_id: i64, + pub telegram_id: TelegramUserId, pub username: Option, pub display_name: Option, } diff --git a/src/db/money_amount.rs b/src/db/money_amount.rs index f720f0b..0e03a5e 100644 --- a/src/db/money_amount.rs +++ b/src/db/money_amount.rs @@ -6,8 +6,6 @@ use sqlx::{ use std::ops::{Add, Sub}; use std::str::FromStr; -use super::CurrencyType; - /// 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)] @@ -119,6 +117,7 @@ impl<'r> Decode<'r, Sqlite> for MoneyAmount { #[cfg(test)] mod tests { use super::*; + use crate::db::CurrencyType; use rstest::rstest; use sqlx::{Row, SqlitePool}; diff --git a/src/db/telegram_user_id.rs b/src/db/telegram_user_id.rs new file mode 100644 index 0000000..21d9d8e --- /dev/null +++ b/src/db/telegram_user_id.rs @@ -0,0 +1,79 @@ +//! 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 serde::{Deserialize, Serialize}; +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, Serialize, Deserialize)] +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 for TelegramUserId { + fn from(id: teloxide::types::UserId) -> Self { + Self(id) + } +} + +impl From for TelegramUserId { + fn from(id: u64) -> Self { + Self(teloxide::types::UserId(id)) + } +} + +impl From 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 for TelegramUserId { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + >::compatible(ty) + } +} + +impl<'q> Encode<'q, Sqlite> for TelegramUserId { + fn encode_by_ref( + &self, + args: &mut Vec>, + ) -> Result { + >::encode(self.0 .0 as i64, args) + } +} + +impl<'r> Decode<'r, Sqlite> for TelegramUserId { + fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { + let id = >::decode(value)?; + Ok(Self(teloxide::types::UserId(id as u64))) + } +} diff --git a/src/db/users.rs b/src/db/users.rs deleted file mode 100644 index 099a3a0..0000000 --- a/src/db/users.rs +++ /dev/null @@ -1,101 +0,0 @@ -use anyhow::Result; -use sqlx::SqlitePool; - -use crate::db::models::{NewUser, User, UserSettings}; - -/// User database operations -pub struct UserRepository; - -impl UserRepository { - /// Create a new user in the database - pub async fn create_user(pool: &SqlitePool, new_user: &NewUser) -> Result { - let user = sqlx::query_as::<_, User>( - r#" - INSERT INTO users (telegram_id, username, display_name, is_banned, created_at, updated_at) - VALUES (?1, ?2, ?3, FALSE, datetime('now'), datetime('now')) - RETURNING * - "# - ) - .bind(new_user.telegram_id) - .bind(&new_user.username) - .bind(&new_user.display_name) - .fetch_one(pool) - .await?; - - Ok(user) - } - - /// Find user by Telegram ID - pub async fn find_by_telegram_id(pool: &SqlitePool, telegram_id: i64) -> Result> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE telegram_id = ?1") - .bind(telegram_id) - .fetch_optional(pool) - .await?; - - Ok(user) - } - - /// Find user by internal ID - pub async fn find_by_id(pool: &SqlitePool, user_id: i64) -> Result> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?1") - .bind(user_id) - .fetch_optional(pool) - .await?; - - Ok(user) - } - - /// Get or create user (common pattern for Telegram bots) - pub async fn get_or_create_user( - pool: &SqlitePool, - telegram_id: i64, - username: Option, - display_name: Option, - ) -> Result { - // Try to find existing user - if let Some(user) = Self::find_by_telegram_id(pool, telegram_id).await? { - return Ok(user); - } - - // Create new user if not found - let new_user = NewUser { - telegram_id, - username, - display_name, - }; - - let user = Self::create_user(pool, &new_user).await?; - - // Create default user settings - Self::create_default_settings(pool, user.id).await?; - - Ok(user) - } - - /// Create default user settings - pub async fn create_default_settings(pool: &SqlitePool, user_id: i64) -> Result { - let settings = sqlx::query_as::<_, UserSettings>( - r#" - INSERT INTO user_settings (user_id, language_code, notify_outbid, notify_won, created_at, updated_at) - VALUES (?1, 'en', TRUE, TRUE, datetime('now'), datetime('now')) - RETURNING * - "# - ) - .bind(user_id) - .fetch_one(pool) - .await?; - - Ok(settings) - } - - /// Update user ban status - pub async fn set_ban_status(pool: &SqlitePool, user_id: i64, is_banned: bool) -> Result<()> { - sqlx::query("UPDATE users SET is_banned = ?1, updated_at = datetime('now') WHERE id = ?2") - .bind(is_banned) - .bind(user_id) - .execute(pool) - .await?; - - Ok(()) - } -}