diff --git a/src/entities/mod.rs b/src/entities/mod.rs index ff0466e..707fb9d 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -3,6 +3,7 @@ pub mod artist; pub mod download_queue; pub mod search_cache; pub mod track; +pub mod user; pub mod wanted_item; pub use album::Entity as Albums; @@ -10,4 +11,5 @@ pub use artist::Entity as Artists; pub use download_queue::Entity as DownloadQueue; pub use search_cache::Entity as SearchCache; pub use track::Entity as Tracks; +pub use user::Entity as Users; pub use wanted_item::Entity as WantedItems; diff --git a/src/entities/user.rs b/src/entities/user.rs new file mode 100644 index 0000000..f00a8a9 --- /dev/null +++ b/src/entities/user.rs @@ -0,0 +1,39 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Text")] +pub enum UserRole { + #[sea_orm(string_value = "admin")] + Admin, + #[sea_orm(string_value = "user")] + User, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub role: UserRole, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::wanted_item::Entity")] + WantedItems, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantedItems.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/wanted_item.rs b/src/entities/wanted_item.rs index b8f502f..2ef68ef 100644 --- a/src/entities/wanted_item.rs +++ b/src/entities/wanted_item.rs @@ -37,6 +37,8 @@ pub struct Model { pub album_id: Option, #[sea_orm(nullable)] pub track_id: Option, + #[sea_orm(nullable)] + pub user_id: Option, pub name: String, #[sea_orm(nullable)] pub musicbrainz_id: Option, @@ -47,6 +49,13 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, #[sea_orm( belongs_to = "super::artist::Entity", from = "Column::ArtistId", @@ -72,6 +81,12 @@ pub enum Relation { Downloads, } +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::Artist.def() diff --git a/src/migration/m20260319_000011_create_users.rs b/src/migration/m20260319_000011_create_users.rs new file mode 100644 index 0000000..585337e --- /dev/null +++ b/src/migration/m20260319_000011_create_users.rs @@ -0,0 +1,67 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Users::Table) + .if_not_exists() + .col( + ColumnDef::new(Users::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(Users::Username) + .text() + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(Users::PasswordHash).text().not_null()) + .col( + ColumnDef::new(Users::Role) + .text() + .not_null() + .default("user"), + ) + .col( + ColumnDef::new(Users::CreatedAt) + .date_time() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Users::UpdatedAt) + .date_time() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Users::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub(crate) enum Users { + Table, + Id, + Username, + PasswordHash, + Role, + CreatedAt, + UpdatedAt, +} diff --git a/src/migration/m20260319_000012_add_user_id_to_wanted_items.rs b/src/migration/m20260319_000012_add_user_id_to_wanted_items.rs new file mode 100644 index 0000000..e84a759 --- /dev/null +++ b/src/migration/m20260319_000012_add_user_id_to_wanted_items.rs @@ -0,0 +1,45 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(WantedItems::Table) + .add_column(ColumnDef::new(WantedItems::UserId).integer()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_wanted_items_user_id") + .table(WantedItems::Table) + .col(WantedItems::UserId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(WantedItems::Table) + .drop_column(WantedItems::UserId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum WantedItems { + Table, + UserId, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index 0a5b86c..eda814a 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -9,6 +9,8 @@ mod m20260317_000006_create_search_cache; mod m20260317_000007_unique_artist_album; mod m20260317_000009_add_wanted_name; mod m20260317_000010_add_wanted_mbid; +mod m20260319_000011_create_users; +mod m20260319_000012_add_user_id_to_wanted_items; pub struct Migrator; @@ -25,6 +27,8 @@ impl MigratorTrait for Migrator { Box::new(m20260317_000007_unique_artist_album::Migration), Box::new(m20260317_000009_add_wanted_name::Migration), Box::new(m20260317_000010_add_wanted_mbid::Migration), + Box::new(m20260319_000011_create_users::Migration), + Box::new(m20260319_000012_add_user_id_to_wanted_items::Migration), ] } } diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 0dab9a3..303c233 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -3,4 +3,5 @@ pub mod artists; pub mod cache; pub mod downloads; pub mod tracks; +pub mod users; pub mod wanted; diff --git a/src/queries/users.rs b/src/queries/users.rs new file mode 100644 index 0000000..ddc9dbd --- /dev/null +++ b/src/queries/users.rs @@ -0,0 +1,64 @@ +use chrono::Utc; +use sea_orm::*; + +use crate::entities::user::{self, ActiveModel, Entity as Users, Model as User, UserRole}; +use crate::error::{DbError, DbResult}; + +pub async fn create( + db: &DatabaseConnection, + username: &str, + password_hash: &str, + role: UserRole, +) -> DbResult { + let now = Utc::now().naive_utc(); + let active = ActiveModel { + username: Set(username.to_string()), + password_hash: Set(password_hash.to_string()), + role: Set(role), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + Ok(active.insert(db).await?) +} + +pub async fn find_by_username(db: &DatabaseConnection, username: &str) -> DbResult> { + Ok(Users::find() + .filter(user::Column::Username.eq(username)) + .one(db) + .await?) +} + +pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult { + Users::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| DbError::NotFound(format!("user id={id}"))) +} + +pub async fn list(db: &DatabaseConnection) -> DbResult> { + Ok(Users::find().order_by_asc(user::Column::Username).all(db).await?) +} + +pub async fn delete(db: &DatabaseConnection, id: i32) -> DbResult<()> { + Users::delete_by_id(id).exec(db).await?; + Ok(()) +} + +pub async fn count(db: &DatabaseConnection) -> DbResult { + Ok(Users::find().count(db).await?) +} + +/// Assign all orphaned wanted items (user_id IS NULL) to the given user. +pub async fn adopt_orphaned_wanted_items(db: &DatabaseConnection, user_id: i32) -> DbResult { + use crate::entities::wanted_item; + let result = wanted_item::Entity::update_many() + .col_expr( + wanted_item::Column::UserId, + sea_orm::sea_query::Expr::value(user_id), + ) + .filter(wanted_item::Column::UserId.is_null()) + .exec(db) + .await?; + Ok(result.rows_affected) +} diff --git a/src/queries/wanted.rs b/src/queries/wanted.rs index d316a75..55db4c9 100644 --- a/src/queries/wanted.rs +++ b/src/queries/wanted.rs @@ -7,23 +7,26 @@ use crate::entities::wanted_item::{ }; use crate::error::{DbError, DbResult}; -pub async fn add( - db: &DatabaseConnection, - item_type: ItemType, - name: &str, - musicbrainz_id: Option<&str>, - artist_id: Option, - album_id: Option, - track_id: Option, -) -> DbResult { +pub struct AddWantedItem<'a> { + pub item_type: ItemType, + pub name: &'a str, + pub musicbrainz_id: Option<&'a str>, + pub artist_id: Option, + pub album_id: Option, + pub track_id: Option, + pub user_id: Option, +} + +pub async fn add(db: &DatabaseConnection, item: AddWantedItem<'_>) -> DbResult { let now = Utc::now().naive_utc(); let active = ActiveModel { - item_type: Set(item_type), - name: Set(name.to_string()), - musicbrainz_id: Set(musicbrainz_id.map(String::from)), - artist_id: Set(artist_id), - album_id: Set(album_id), - track_id: Set(track_id), + item_type: Set(item.item_type), + name: Set(item.name.to_string()), + musicbrainz_id: Set(item.musicbrainz_id.map(String::from)), + artist_id: Set(item.artist_id), + album_id: Set(item.album_id), + track_id: Set(item.track_id), + user_id: Set(item.user_id), status: Set(WantedStatus::Wanted), added_at: Set(now), updated_at: Set(now), @@ -35,11 +38,15 @@ pub async fn add( pub async fn list( db: &DatabaseConnection, status_filter: Option, + user_id: Option, ) -> DbResult> { let mut query = WantedItems::find(); if let Some(status) = status_filter { query = query.filter(wanted_item::Column::Status.eq(status)); } + if let Some(uid) = user_id { + query = query.filter(wanted_item::Column::UserId.eq(uid)); + } Ok(query.all(db).await?) } diff --git a/tests/integration.rs b/tests/integration.rs index d565319..0ef7c56 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -167,12 +167,15 @@ async fn test_wanted_items_lifecycle() { // Add wanted item let item = queries::wanted::add( conn, - ItemType::Artist, - "Radiohead", - None, - Some(artist.id), - None, - None, + queries::wanted::AddWantedItem { + item_type: ItemType::Artist, + name: "Radiohead", + musicbrainz_id: None, + artist_id: Some(artist.id), + album_id: None, + track_id: None, + user_id: None, + }, ) .await .unwrap(); @@ -180,12 +183,12 @@ async fn test_wanted_items_lifecycle() { assert_eq!(item.item_type, ItemType::Artist); // List with filter - let wanted = queries::wanted::list(conn, Some(WantedStatus::Wanted)) + let wanted = queries::wanted::list(conn, Some(WantedStatus::Wanted), None) .await .unwrap(); assert_eq!(wanted.len(), 1); - let downloaded = queries::wanted::list(conn, Some(WantedStatus::Downloaded)) + let downloaded = queries::wanted::list(conn, Some(WantedStatus::Downloaded), None) .await .unwrap(); assert!(downloaded.is_empty()); @@ -198,7 +201,7 @@ async fn test_wanted_items_lifecycle() { // Remove queries::wanted::remove(conn, item.id).await.unwrap(); - let all = queries::wanted::list(conn, None).await.unwrap(); + let all = queries::wanted::list(conn, None, None).await.unwrap(); assert!(all.is_empty()); }