diff --git a/api/migration/src/lib.rs b/api/migration/src/lib.rs index 52cee10..65f85e7 100644 --- a/api/migration/src/lib.rs +++ b/api/migration/src/lib.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20260503_093223_add_tag_table; +mod m20260503_125344_add_user_category; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20260503_093223_add_tag_table::Migration)] + vec![ + Box::new(m20260503_093223_add_tag_table::Migration), + Box::new(m20260503_125344_add_user_category::Migration), + ] } } diff --git a/api/migration/src/m20260503_125344_add_user_category.rs b/api/migration/src/m20260503_125344_add_user_category.rs new file mode 100644 index 0000000..76f39a6 --- /dev/null +++ b/api/migration/src/m20260503_125344_add_user_category.rs @@ -0,0 +1,39 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[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(Category::Table) + .if_not_exists() + .col(pk_uuid(Category::Id)) + .col(string(Category::Name)) + .col(string(Category::Color)) + .col(string(Category::Icon)) + .col(uuid(Category::OwnerId)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Category::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Category { + Table, + Id, + OwnerId, + Name, + Color, + Icon, +} diff --git a/api/src/entities/category.rs b/api/src/entities/category.rs new file mode 100644 index 0000000..8270671 --- /dev/null +++ b/api/src/entities/category.rs @@ -0,0 +1,19 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "category")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + pub color: String, + pub icon: String, + pub owner_id: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/api/src/entities/mod.rs b/api/src/entities/mod.rs index 7efb75e..44272eb 100644 --- a/api/src/entities/mod.rs +++ b/api/src/entities/mod.rs @@ -2,4 +2,5 @@ pub mod prelude; +pub mod category; pub mod tag; diff --git a/api/src/entities/prelude.rs b/api/src/entities/prelude.rs index 4384203..e8cc02b 100644 --- a/api/src/entities/prelude.rs +++ b/api/src/entities/prelude.rs @@ -1,3 +1,4 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 +pub use super::category::Entity as Category; pub use super::tag::Entity as Tag; diff --git a/api/src/routes/categories.rs b/api/src/routes/categories.rs new file mode 100644 index 0000000..7c4882b --- /dev/null +++ b/api/src/routes/categories.rs @@ -0,0 +1,147 @@ +use axum::extract::Path; +use axum::routing::{delete, post}; +use axum::{Json, Router, extract::State, routing::get}; +use http::StatusCode; +use sea_orm::ActiveValue::Set; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use tracing::warn; +use types::{Category as CategoryModel, HexColor}; +use uuid::Uuid; + +use crate::{AppState, auth::User}; + +use crate::entities::{category, prelude::*}; + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/", get(get_all_categories)) + .route("/", post(add_category)) + .route("/{category_uuid}", delete(delete_category)) + .with_state(state) +} + +async fn add_category( + state: State, + user: User, + Json(category): Json, +) -> Result, (StatusCode, &'static str)> { + let existing_category = Category::find_by_id(category.uuid) + .one(&state.db_connection) + .await + .map_err(|e| { + warn!("failed to find category: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "failed to find category") + })?; + + let category = category::ActiveModel { + id: Set(category.uuid), + owner_id: Set(user.uuid), + name: Set(category.name), + color: Set(category.color.as_str().to_string()), + icon: Set(category.icon), + }; + + let new_category = match existing_category { + Some(existing_category) => { + Category::update(category) + .exec(&state.db_connection) + .await + .map_err(|e| { + warn!("failed to add category: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "failed to add category") + })?; + + CategoryModel { + uuid: existing_category.id, + name: existing_category.name, + color: HexColor::from_str(&existing_category.color).unwrap(), + icon: existing_category.icon, + } + } + None => { + let new_category = Category::insert(category) + .exec_with_returning(&state.db_connection) + .await + .map_err(|e| { + warn!("failed to add category: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "failed to add category") + })?; + + CategoryModel { + uuid: new_category.id, + name: new_category.name, + color: HexColor::from_str(&new_category.color).unwrap(), + icon: new_category.icon, + } + } + }; + + Ok(Json(new_category)) +} + +async fn get_all_categories( + state: State, + user: User, +) -> Result>, (StatusCode, &'static str)> { + let categories = Category::find() + .filter(category::Column::OwnerId.eq(user.uuid)) + .all(&state.db_connection) + .await + .map_err(|e| { + warn!("failed to fetch category: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to fetch categories", + ) + })?; + + Ok(Json( + categories + .into_iter() + .map(|category| CategoryModel { + uuid: category.id, + name: category.name, + color: HexColor::from_str(&category.color).unwrap(), + icon: category.icon, + }) + .collect(), + )) +} + +async fn delete_category( + state: State, + Path(category_uuid): Path, + user: User, +) -> Result, (StatusCode, &'static str)> { + let deleted_category = Category::find_by_id(category_uuid) + .filter(category::Column::OwnerId.eq(user.uuid)) + .one(&state.db_connection) + .await + .map_err(|e| { + warn!("failed to fetch category: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to fetch categories", + ) + })?; + + let Some(category) = deleted_category else { + return Err((StatusCode::NOT_FOUND, "category not found")); + }; + + let deleted_category = CategoryModel { + uuid: category.id, + name: category.name.to_string(), + color: HexColor::from_str(&category.color).unwrap(), + icon: category.icon.to_string(), + }; + + category.delete(&state.db_connection).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to delete category", + ) + })?; + + Ok(Json(deleted_category)) +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 8608c49..2a7b357 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -2,8 +2,11 @@ use axum::Router; use crate::AppState; +mod categories; mod tags; pub fn routes(state: AppState) -> Router { - Router::new().nest("/tags", tags::routes(state.clone())) + Router::new() + .nest("/tags", tags::routes(state.clone())) + .nest("/categories", categories::routes(state.clone())) } diff --git a/api/tests/categories/add.rs b/api/tests/categories/add.rs index 9d4a335..52a0960 100644 --- a/api/tests/categories/add.rs +++ b/api/tests/categories/add.rs @@ -55,7 +55,7 @@ async fn add_invalid_category() { } )) .await; - assert_eq!(response.status().as_str(), "400"); + assert_eq!(response.status().as_str(), "422"); let response = client .add_category(json!( @@ -67,5 +67,5 @@ async fn add_invalid_category() { } )) .await; - assert_eq!(response.status().as_str(), "400"); + assert_eq!(response.status().as_str(), "422"); }