feat: implement type-safe ID system and enum-based listing architecture

- Add UserId and ListingId newtype wrappers with SQLx integration
  - Prevents mixing up different ID types at compile time
  - Custom Encode/Decode traits for seamless database operations
  - Zero-cost abstractions with full type safety

- Refactor Listing to enum-based architecture
  - BasicAuction: Traditional time-based auction with bidding
  - MultiSlotAuction: Auction with multiple winners/slots
  - FixedPriceListing: Fixed price sale with no bidding
  - BlindAuction: Blind auction where seller chooses winner
  - ListingBase: Common fields shared by all listing types
  - ListingRow: Flat database row structure for SQLx compatibility

- Update ListingType enum variants
  - Standard -> BasicAuction
  - MultiSlot -> MultiSlotAuction
  - FixedPrice -> FixedPriceListing
  - Blind -> BlindAuction

- Add comprehensive conversion traits and helper methods
- Maintain full database compatibility with existing schema
- Remove unused helper methods and make base() private for cleaner API
This commit is contained in:
Dylan Knutson
2025-08-27 23:38:10 +00:00
parent eca570c887
commit fc1a61ff32
5 changed files with 549 additions and 29 deletions

71
src/db/listing_id.rs Normal file
View File

@@ -0,0 +1,71 @@
//! 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,10 +1,14 @@
pub mod currency_type;
pub mod listing_id;
pub mod models;
pub mod money_amount;
pub mod user_id;
pub mod users;
// Re-export all models for easy access
pub use currency_type::*;
pub use listing_id::*;
pub use models::*;
pub use money_amount::*;
pub use user_id::*;
pub use users::UserRepository;

View File

@@ -1,48 +1,422 @@
//! Listing models with type-safe enum-based structure
//!
//! This module provides a type-safe representation of different listing types:
//! - `BasicAuction`: Traditional time-based auction with bidding
//! - `MultiSlotAuction`: Auction with multiple winners/slots available
//! - `FixedPriceListing`: Fixed price sale with no bidding
//! - `BlindAuction`: Blind auction where seller chooses winner
//!
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
//! Database mapping is handled through `ListingRow` with conversion traits.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use super::listing_type::ListingType;
use crate::db::money_amount::MoneyAmount;
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
/// Main listing/auction entity
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct Listing {
pub id: i64,
pub seller_id: i64,
/// Common fields shared by all listing types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListingBase {
pub id: ListingId,
pub seller_id: UserId,
pub title: String,
pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Traditional time-based auction with bidding
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BasicAuction {
#[serde(flatten)]
pub base: ListingBase,
pub starting_bid: Option<MoneyAmount>,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub anti_snipe_minutes: i32,
}
/// Auction with multiple winners/slots available
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiSlotAuction {
#[serde(flatten)]
pub base: ListingBase,
pub starting_bid: Option<MoneyAmount>,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub slots_available: i32,
pub anti_snipe_minutes: i32,
}
/// Fixed price sale with no bidding
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixedPriceListing {
#[serde(flatten)]
pub base: ListingBase,
pub buy_now_price: MoneyAmount,
pub slots_available: i32,
}
/// Blind auction where seller chooses winner
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlindAuction {
#[serde(flatten)]
pub base: ListingBase,
}
/// Main listing/auction entity enum
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "listing_type")]
pub enum Listing {
#[serde(rename = "basic_auction")]
BasicAuction(BasicAuction),
#[serde(rename = "multi_slot_auction")]
MultiSlotAuction(MultiSlotAuction),
#[serde(rename = "fixed_price_listing")]
FixedPriceListing(FixedPriceListing),
#[serde(rename = "blind_auction")]
BlindAuction(BlindAuction),
}
/// New listing data for insertion
#[derive(Debug, Clone)]
pub enum NewListing {
BasicAuction {
seller_id: UserId,
title: String,
description: Option<String>,
starting_bid: Option<MoneyAmount>,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
anti_snipe_minutes: i32,
},
MultiSlotAuction {
seller_id: UserId,
title: String,
description: Option<String>,
starting_bid: Option<MoneyAmount>,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
slots_available: i32,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
anti_snipe_minutes: i32,
},
FixedPriceListing {
seller_id: UserId,
title: String,
description: Option<String>,
buy_now_price: MoneyAmount,
slots_available: i32,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
},
BlindAuction {
seller_id: UserId,
title: String,
description: Option<String>,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
},
}
impl Listing {
/// Get the base information common to all listing types
fn base(&self) -> &ListingBase {
match self {
Listing::BasicAuction(auction) => &auction.base,
Listing::MultiSlotAuction(auction) => &auction.base,
Listing::FixedPriceListing(listing) => &listing.base,
Listing::BlindAuction(auction) => &auction.base,
}
}
/// Get the listing type as an enum value
pub fn listing_type(&self) -> ListingType {
match self {
Listing::BasicAuction(_) => ListingType::BasicAuction,
Listing::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
Listing::FixedPriceListing(_) => ListingType::FixedPriceListing,
Listing::BlindAuction(_) => ListingType::BlindAuction,
}
}
/// Get the seller ID
pub fn seller_id(&self) -> UserId {
self.base().seller_id
}
/// Get the listing title
pub fn title(&self) -> &str {
&self.base().title
}
/// Get the listing description
pub fn description(&self) -> Option<&str> {
self.base().description.as_deref()
}
}
impl NewListing {
/// Get the listing type for this new listing
pub fn listing_type(&self) -> ListingType {
match self {
NewListing::BasicAuction { .. } => ListingType::BasicAuction,
NewListing::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
NewListing::FixedPriceListing { .. } => ListingType::FixedPriceListing,
NewListing::BlindAuction { .. } => ListingType::BlindAuction,
}
}
/// Get the seller ID for this new listing
pub fn seller_id(&self) -> UserId {
match self {
NewListing::BasicAuction { seller_id, .. } => *seller_id,
NewListing::MultiSlotAuction { seller_id, .. } => *seller_id,
NewListing::FixedPriceListing { seller_id, .. } => *seller_id,
NewListing::BlindAuction { seller_id, .. } => *seller_id,
}
}
/// Get the title for this new listing
pub fn title(&self) -> &str {
match self {
NewListing::BasicAuction { title, .. } => title,
NewListing::MultiSlotAuction { title, .. } => title,
NewListing::FixedPriceListing { title, .. } => title,
NewListing::BlindAuction { title, .. } => title,
}
}
/// Create a new basic auction listing
pub fn new_basic_auction(
seller_id: UserId,
title: String,
description: Option<String>,
starting_bid: Option<MoneyAmount>,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
anti_snipe_minutes: i32,
) -> Self {
NewListing::BasicAuction {
seller_id,
title,
description,
starting_bid,
buy_now_price,
min_increment,
starts_at,
ends_at,
anti_snipe_minutes,
}
}
/// Create a new multi-slot auction listing
pub fn new_multi_slot_auction(
seller_id: UserId,
title: String,
description: Option<String>,
starting_bid: Option<MoneyAmount>,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
slots_available: i32,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
anti_snipe_minutes: i32,
) -> Self {
NewListing::MultiSlotAuction {
seller_id,
title,
description,
starting_bid,
buy_now_price,
min_increment,
slots_available,
starts_at,
ends_at,
anti_snipe_minutes,
}
}
/// Create a new fixed price listing
pub fn new_fixed_price_listing(
seller_id: UserId,
title: String,
description: Option<String>,
buy_now_price: MoneyAmount,
slots_available: i32,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
) -> Self {
NewListing::FixedPriceListing {
seller_id,
title,
description,
buy_now_price,
slots_available,
starts_at,
ends_at,
}
}
/// Create a new blind auction listing
pub fn new_blind_auction(
seller_id: UserId,
title: String,
description: Option<String>,
starts_at: DateTime<Utc>,
ends_at: DateTime<Utc>,
) -> Self {
NewListing::BlindAuction {
seller_id,
title,
description,
starts_at,
ends_at,
}
}
}
/// Flat database row structure for SQLx FromRow compatibility
#[derive(Debug, Clone, FromRow)]
pub struct ListingRow {
pub id: ListingId,
pub seller_id: UserId,
pub listing_type: ListingType,
pub title: String,
pub description: Option<String>,
// Pricing fields
// Pricing fields - nullable for types that don't use them
pub starting_bid: Option<MoneyAmount>,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub min_increment: Option<MoneyAmount>,
// Multi-slot/fixed price
pub slots_available: i32,
pub slots_available: Option<i32>,
// Timing
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub anti_snipe_minutes: i32,
pub anti_snipe_minutes: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// New listing data for insertion
#[derive(Debug, Clone)]
pub struct NewListing {
pub seller_id: i64,
pub listing_type: ListingType,
pub title: String,
pub description: Option<String>,
pub starting_bid: Option<MoneyAmount>,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub slots_available: i32,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub anti_snipe_minutes: i32,
impl From<ListingRow> for Listing {
fn from(row: ListingRow) -> Self {
let base = ListingBase {
id: row.id,
seller_id: row.seller_id,
title: row.title,
description: row.description,
starts_at: row.starts_at,
ends_at: row.ends_at,
created_at: row.created_at,
updated_at: row.updated_at,
};
match row.listing_type {
ListingType::BasicAuction => Listing::BasicAuction(BasicAuction {
base,
starting_bid: row.starting_bid,
buy_now_price: row.buy_now_price,
min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero),
anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5),
}),
ListingType::MultiSlotAuction => Listing::MultiSlotAuction(MultiSlotAuction {
base,
starting_bid: row.starting_bid,
buy_now_price: row.buy_now_price,
min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero),
slots_available: row.slots_available.unwrap_or(1),
anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5),
}),
ListingType::FixedPriceListing => Listing::FixedPriceListing(FixedPriceListing {
base,
buy_now_price: row.buy_now_price.unwrap_or_else(MoneyAmount::zero),
slots_available: row.slots_available.unwrap_or(1),
}),
ListingType::BlindAuction => Listing::BlindAuction(BlindAuction { base }),
}
}
}
impl From<Listing> for ListingRow {
fn from(listing: Listing) -> Self {
match listing {
Listing::BasicAuction(auction) => ListingRow {
id: auction.base.id,
seller_id: auction.base.seller_id,
listing_type: ListingType::BasicAuction,
title: auction.base.title,
description: auction.base.description,
starting_bid: auction.starting_bid,
buy_now_price: auction.buy_now_price,
min_increment: Some(auction.min_increment),
slots_available: Some(1),
starts_at: auction.base.starts_at,
ends_at: auction.base.ends_at,
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
created_at: auction.base.created_at,
updated_at: auction.base.updated_at,
},
Listing::MultiSlotAuction(auction) => ListingRow {
id: auction.base.id,
seller_id: auction.base.seller_id,
listing_type: ListingType::MultiSlotAuction,
title: auction.base.title,
description: auction.base.description,
starting_bid: auction.starting_bid,
buy_now_price: auction.buy_now_price,
min_increment: Some(auction.min_increment),
slots_available: Some(auction.slots_available),
starts_at: auction.base.starts_at,
ends_at: auction.base.ends_at,
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
created_at: auction.base.created_at,
updated_at: auction.base.updated_at,
},
Listing::FixedPriceListing(listing) => ListingRow {
id: listing.base.id,
seller_id: listing.base.seller_id,
listing_type: ListingType::FixedPriceListing,
title: listing.base.title,
description: listing.base.description,
starting_bid: None,
buy_now_price: Some(listing.buy_now_price),
min_increment: None,
slots_available: Some(listing.slots_available),
starts_at: listing.base.starts_at,
ends_at: listing.base.ends_at,
anti_snipe_minutes: None,
created_at: listing.base.created_at,
updated_at: listing.base.updated_at,
},
Listing::BlindAuction(auction) => ListingRow {
id: auction.base.id,
seller_id: auction.base.seller_id,
listing_type: ListingType::BlindAuction,
title: auction.base.title,
description: auction.base.description,
starting_bid: None,
buy_now_price: None,
min_increment: None,
slots_available: Some(1),
starts_at: auction.base.starts_at,
ends_at: auction.base.ends_at,
anti_snipe_minutes: None,
created_at: auction.base.created_at,
updated_at: auction.base.updated_at,
},
}
}
}

View File

@@ -3,14 +3,14 @@ use serde::{Deserialize, Serialize};
/// Types of listings supported by the platform
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "TEXT")]
#[sqlx(rename_all = "lowercase")]
#[sqlx(rename_all = "snake_case")]
pub enum ListingType {
/// Traditional time-based auction with bidding
Standard,
BasicAuction,
/// Auction with multiple winners/slots available
MultiSlot,
MultiSlotAuction,
/// Fixed price sale with no bidding
FixedPrice,
FixedPriceListing,
/// Blind auction where seller chooses winner
Blind,
BlindAuction,
}

71
src/db/user_id.rs Normal file
View File

@@ -0,0 +1,71 @@
//! 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 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 UserId(i64);
impl UserId {
/// 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 UserId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<UserId> for i64 {
fn from(user_id: UserId) -> Self {
user_id.0
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for UserId {
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 UserId {
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 UserId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}