diff --git a/api/src/routes/tags.rs b/api/src/routes/tags.rs index 7b0f9bb..1ed33c9 100644 --- a/api/src/routes/tags.rs +++ b/api/src/routes/tags.rs @@ -1,18 +1,47 @@ +use axum::extract::Path; +use axum::routing::{delete, post}; use axum::{Json, Router, extract::State, routing::get}; use http::StatusCode; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use tracing::info; -use types::Tag as TagModel; +use sea_orm::ActiveValue::Set; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use types::{HexColor, Tag as TagModel}; +use uuid::Uuid; use crate::{AppState, auth::User}; use crate::entities::{prelude::*, tag}; pub fn routes(state: AppState) -> Router { - Router::new().route("/", get(get_all)).with_state(state) + Router::new() + .route("/", get(get_all_tags)) + .route("/", post(add_tag)) + .route("/{tag_uuid}", delete(delete_tag)) + .with_state(state) } -async fn get_all( +async fn add_tag( + state: State, + user: User, + Json(tag): Json, +) -> Result, (StatusCode, &'static str)> { + let new_tag = Tag::insert(tag::ActiveModel { + id: Set(tag.uuid), + owner_id: Set(user.uuid), + name: Set(tag.name), + color: Set(tag.color.as_str().to_string()), + }) + .exec_with_returning(&state.db_connection) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to add tag"))?; + + Ok(Json(TagModel { + uuid: new_tag.id, + name: new_tag.name, + color: HexColor::from_str(&new_tag.color).unwrap(), + })) +} + +async fn get_all_tags( state: State, user: User, ) -> Result>, (StatusCode, &'static str)> { @@ -22,15 +51,41 @@ async fn get_all( .await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to fetch tags"))?; - info!("{:?}", user.uuid); - Ok(Json( tags.into_iter() .map(|tag| TagModel { uuid: tag.id, name: tag.name, - color: tag.color, + color: HexColor::from_str(&tag.color).unwrap(), }) .collect(), )) } + +async fn delete_tag( + state: State, + Path(tag_uuid): Path, + user: User, +) -> Result, (StatusCode, &'static str)> { + let deleted_tag = Tag::find_by_id(tag_uuid) + .filter(tag::Column::OwnerId.eq(user.uuid)) + .one(&state.db_connection) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to fetch tags"))?; + + let Some(tag) = deleted_tag else { + return Err((StatusCode::NOT_FOUND, "tag not found")); + }; + + let deleted_tag = TagModel { + uuid: tag.id, + name: tag.name.to_string(), + color: HexColor::from_str(&tag.color).unwrap(), + }; + + tag.delete(&state.db_connection) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed to delete tag"))?; + + Ok(Json(deleted_tag)) +} diff --git a/api/tests/categories/add.rs b/api/tests/categories/add.rs index 1b279c1..9d4a335 100644 --- a/api/tests/categories/add.rs +++ b/api/tests/categories/add.rs @@ -1,7 +1,7 @@ use crate::common::{client::get_client, db::clear_db}; use serde_json::json; use serial_test::serial; -use types::Category; +use types::{Category, HexColor}; use uuid::Uuid; #[tokio::test] @@ -14,7 +14,7 @@ async fn add_category() { let category = Category { uuid: category_uuid, name: "Test Tag".to_string(), - color: "#233212".to_string(), + color: HexColor::from_str("#233212").unwrap(), icon: "icon".to_string(), }; diff --git a/api/tests/tags/add.rs b/api/tests/tags/add.rs index 7e78f16..092e04d 100644 --- a/api/tests/tags/add.rs +++ b/api/tests/tags/add.rs @@ -1,7 +1,7 @@ use crate::common::{client::get_client, db::clear_db}; use serde_json::json; use serial_test::serial; -use types::Tag; +use types::{HexColor, Tag}; use uuid::Uuid; #[tokio::test] @@ -14,7 +14,7 @@ async fn add_tag() { let tag = Tag { uuid: tag_uuid, name: "Test Tag".to_string(), - color: "#233212".to_string(), + color: HexColor::from_str("#233212").unwrap(), }; let response = client @@ -52,7 +52,7 @@ async fn add_invalid_tag() { } )) .await; - assert_eq!(response.status().as_str(), "400"); + assert_eq!(response.status().as_str(), "422"); let response = client .add_tag(json!( @@ -63,5 +63,5 @@ async fn add_invalid_tag() { } )) .await; - assert_eq!(response.status().as_str(), "400"); + assert_eq!(response.status().as_str(), "422"); } diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index a34ad59..4036b5d 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -1,7 +1,7 @@ use std::ops::Add; use chrono::{DateTime, Days, NaiveDate, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -124,17 +124,58 @@ impl Time { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HexColor(String); + +impl HexColor { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn from_str(s: &str) -> Option { + if is_valid_hex_color(s) { + Some(HexColor(s.to_string())) + } else { + None + } + } +} + +impl<'de> Deserialize<'de> for HexColor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + HexColor::from_str(&s).ok_or(serde::de::Error::custom("invalid hex color")) + } +} + +impl Serialize for HexColor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +fn is_valid_hex_color(s: &str) -> bool { + let s = s.strip_prefix('#').unwrap_or(s); + matches!(s.len(), 3 | 6) && s.chars().all(|c| c.is_ascii_hexdigit()) +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Tag { pub uuid: Uuid, pub name: String, - pub color: String, + pub color: HexColor, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Category { pub uuid: Uuid, pub name: String, - pub color: String, + pub color: HexColor, pub icon: String, }