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
This commit is contained in:
Dylan Knutson
2025-08-28 01:15:40 +00:00
parent 7a16c5859b
commit 3a7d0a6905
11 changed files with 806 additions and 127 deletions

View File

@@ -10,9 +10,9 @@ CREATE TABLE users (
telegram_id INTEGER UNIQUE NOT NULL, telegram_id INTEGER UNIQUE NOT NULL,
username TEXT, username TEXT,
display_name TEXT, display_name TEXT,
is_banned BOOLEAN DEFAULT FALSE, is_banned INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TEXT DEFAULT CURRENT_TIMESTAMP
) STRICT; ) STRICT;
-- Main listing table (handles all listing types) -- Main listing table (handles all listing types)
@@ -32,12 +32,12 @@ CREATE TABLE listings (
slots_available INTEGER DEFAULT 1, slots_available INTEGER DEFAULT 1,
-- Timing -- Timing
starts_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, starts_at TEXT DEFAULT CURRENT_TIMESTAMP,
ends_at TIMESTAMP NOT NULL, ends_at TEXT NOT NULL,
anti_snipe_minutes INTEGER DEFAULT 5, anti_snipe_minutes INTEGER DEFAULT 5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (seller_id) REFERENCES users(id) FOREIGN KEY (seller_id) REFERENCES users(id)
) STRICT; ) STRICT;
@@ -47,10 +47,10 @@ CREATE TABLE proxy_bids (
listing_id INTEGER NOT NULL, listing_id INTEGER NOT NULL,
buyer_id INTEGER NOT NULL, buyer_id INTEGER NOT NULL,
max_amount INTEGER NOT NULL, -- stored as cents max_amount INTEGER NOT NULL, -- stored as cents
is_active BOOLEAN DEFAULT TRUE, is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (listing_id) REFERENCES listings(id), FOREIGN KEY (listing_id) REFERENCES listings(id),
FOREIGN KEY (buyer_id) REFERENCES users(id), FOREIGN KEY (buyer_id) REFERENCES users(id),
@@ -68,14 +68,14 @@ CREATE TABLE bids (
description TEXT, description TEXT,
-- Status -- Status
is_cancelled BOOLEAN DEFAULT FALSE, is_cancelled INTEGER DEFAULT 0,
slot_number INTEGER, -- For multi-slot listings slot_number INTEGER, -- For multi-slot listings
-- NULL = manual bid, NOT NULL = generated from proxy -- NULL = manual bid, NOT NULL = generated from proxy
proxy_bid_id INTEGER, proxy_bid_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (listing_id) REFERENCES listings(id), FOREIGN KEY (listing_id) REFERENCES listings(id),
FOREIGN KEY (buyer_id) REFERENCES users(id), FOREIGN KEY (buyer_id) REFERENCES users(id),
FOREIGN KEY (proxy_bid_id) REFERENCES proxy_bids(id) FOREIGN KEY (proxy_bid_id) REFERENCES proxy_bids(id)
@@ -88,8 +88,8 @@ CREATE TABLE listing_medias (
telegram_file_id TEXT NOT NULL, telegram_file_id TEXT NOT NULL,
media_type TEXT NOT NULL, -- 'photo', 'video' media_type TEXT NOT NULL, -- 'photo', 'video'
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (listing_id) REFERENCES listings(id) FOREIGN KEY (listing_id) REFERENCES listings(id)
) STRICT; ) STRICT;
@@ -97,10 +97,10 @@ CREATE TABLE listing_medias (
CREATE TABLE user_settings ( CREATE TABLE user_settings (
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
language_code TEXT DEFAULT 'en', language_code TEXT DEFAULT 'en',
notify_outbid BOOLEAN DEFAULT TRUE, notify_outbid INTEGER DEFAULT 1,
notify_won BOOLEAN DEFAULT TRUE, notify_won INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
) STRICT; ) STRICT;

233
src/db/dao/listing_dao.rs Normal file
View File

@@ -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<Listing> {
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<Option<Listing>> {
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<Vec<Listing>> {
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::<Result<Vec<_>>>()?)
}
/// Find all listings of a specific type
pub async fn find_by_type(
pool: &SqlitePool,
listing_type: ListingType,
) -> Result<Vec<Listing>> {
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::<Result<Vec<_>>>()?)
}
/// Find active listings (not ended yet)
pub async fn find_active_listings(pool: &SqlitePool) -> Result<Vec<Listing>> {
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::<Result<Vec<_>>>()?)
}
/// 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<i64> {
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<i64> {
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<Listing> {
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 })
}
}

6
src/db/dao/mod.rs Normal file
View File

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

323
src/db/dao/user_dao.rs Normal file
View File

@@ -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<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?;
Ok(user)
}
/// Find a user by their ID
pub async fn find_by_id(pool: &SqlitePool, user_id: UserId) -> 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 = ?"
)
.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<Option<User>> {
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<User> {
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<String>,
display_name: Option<String>,
) -> Result<User> {
// Try to find existing user first
if let Some(existing_user) = Self::find_by_telegram_id(pool, telegram_id).await? {
return Ok(existing_user);
}
// Create new user if not found
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());
}
}

View File

@@ -1,14 +1,16 @@
pub mod currency_type; pub mod currency_type;
pub mod dao;
pub mod listing_id; pub mod listing_id;
pub mod models; pub mod models;
pub mod money_amount; pub mod money_amount;
pub mod telegram_user_id;
pub mod user_id; pub mod user_id;
pub mod users;
// Re-export all models for easy access // Re-export all models for easy access
pub use currency_type::*; pub use currency_type::*;
pub use dao::*;
pub use listing_id::*; pub use listing_id::*;
pub use models::*; pub use models::*;
pub use money_amount::*; pub use money_amount::*;
pub use telegram_user_id::*;
pub use user_id::*; pub use user_id::*;
pub use users::UserRepository;

View File

@@ -2,6 +2,7 @@ pub mod bid;
pub mod listing; pub mod listing;
pub mod listing_media; pub mod listing_media;
pub mod listing_type; pub mod listing_type;
pub mod new_listing;
pub mod proxy_bid; pub mod proxy_bid;
pub mod user; pub mod user;
pub mod user_settings; pub mod user_settings;

View File

@@ -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<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
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,
},
}
impl NewListingBase {
pub fn new(
seller_id: UserId,
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

@@ -2,13 +2,16 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use crate::db::{TelegramUserId, UserId};
/// Core user information /// Core user information
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: i64, pub id: UserId,
pub telegram_id: i64, pub telegram_id: TelegramUserId,
pub username: Option<String>, pub username: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,
// SQLite stores booleans as INTEGER (0/1), sqlx FromRow handles the conversion automatically
pub is_banned: bool, pub is_banned: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
@@ -17,7 +20,7 @@ pub struct User {
/// New user data for insertion /// New user data for insertion
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewUser { pub struct NewUser {
pub telegram_id: i64, pub telegram_id: TelegramUserId,
pub username: Option<String>, pub username: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,
} }

View File

@@ -6,8 +6,6 @@ use sqlx::{
use std::ops::{Add, Sub}; use std::ops::{Add, Sub};
use std::str::FromStr; use std::str::FromStr;
use super::CurrencyType;
/// Newtype wrapper for monetary amounts stored as integer cents /// Newtype wrapper for monetary amounts stored as integer cents
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic /// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -119,6 +117,7 @@ impl<'r> Decode<'r, Sqlite> for MoneyAmount {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::CurrencyType;
use rstest::rstest; use rstest::rstest;
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};

View File

@@ -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<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,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<User> {
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<Option<User>> {
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<Option<User>> {
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<String>,
display_name: Option<String>,
) -> Result<User> {
// 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<UserSettings> {
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(())
}
}