@@ -117,3 +223,4 @@ export function AppSidebar() {
>
)
}
+
diff --git a/k-notes-frontend/src/hooks/use-notes.ts b/k-notes-frontend/src/hooks/use-notes.ts
index 27475d2..06a51eb 100644
--- a/k-notes-frontend/src/hooks/use-notes.ts
+++ b/k-notes-frontend/src/hooks/use-notes.ts
@@ -177,3 +177,29 @@ export function useTags() {
queryFn: () => api.get("/tags"),
});
}
+
+export function useDeleteTag() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (id: string) => api.delete(`/tags/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tags"] });
+ queryClient.invalidateQueries({ queryKey: ["notes"] });
+ },
+ });
+}
+
+export function useRenameTag() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, name }: { id: string; name: string }) =>
+ api.patch(`/tags/${id}`, { name }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tags"] });
+ queryClient.invalidateQueries({ queryKey: ["notes"] });
+ },
+ });
+}
+
diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs
index 5aaf823..c47efde 100644
--- a/notes-api/src/dto.rs
+++ b/notes-api/src/dto.rs
@@ -110,6 +110,13 @@ pub struct CreateTagRequest {
pub name: String,
}
+/// Request to rename a tag
+#[derive(Debug, Deserialize, Validate)]
+pub struct RenameTagRequest {
+ #[validate(length(min = 1, max = 50, message = "Tag name must be 1-50 characters"))]
+ pub name: String,
+}
+
/// Login request
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs
index 7da7636..770d18f 100644
--- a/notes-api/src/routes/mod.rs
+++ b/notes-api/src/routes/mod.rs
@@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router
{
.route("/import", post(import_export::import_data))
// Tag routes
.route("/tags", get(tags::list_tags).post(tags::create_tag))
- .route("/tags/{id}", delete(tags::delete_tag))
+ .route(
+ "/tags/{id}",
+ delete(tags::delete_tag).patch(tags::rename_tag),
+ )
}
diff --git a/notes-api/src/routes/tags.rs b/notes-api/src/routes/tags.rs
index fc8ab32..0e959bf 100644
--- a/notes-api/src/routes/tags.rs
+++ b/notes-api/src/routes/tags.rs
@@ -1,9 +1,9 @@
//! Tag route handlers
use axum::{
+ Json,
extract::{Path, State},
http::StatusCode,
- Json,
};
use axum_login::{AuthSession, AuthUser};
use uuid::Uuid;
@@ -12,7 +12,7 @@ use validator::Validate;
use notes_domain::TagService;
use crate::auth::AuthBackend;
-use crate::dto::{CreateTagRequest, TagResponse};
+use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
@@ -22,14 +22,18 @@ pub async fn list_tags(
State(state): State,
auth: AuthSession,
) -> ApiResult>> {
- let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
let user_id = user.id();
let service = TagService::new(state.tag_repo);
-
+
let tags = service.list_tags(user_id).await?;
let response: Vec = tags.into_iter().map(TagResponse::from).collect();
-
+
Ok(Json(response))
}
@@ -40,18 +44,50 @@ pub async fn create_tag(
auth: AuthSession,
Json(payload): Json,
) -> ApiResult<(StatusCode, Json)> {
- let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
let user_id = user.id();
- payload.validate().map_err(|e| ApiError::validation(e.to_string()))?;
-
+ payload
+ .validate()
+ .map_err(|e| ApiError::validation(e.to_string()))?;
+
let service = TagService::new(state.tag_repo);
-
+
let tag = service.create_tag(user_id, &payload.name).await?;
-
+
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
}
+/// Rename a tag
+/// PATCH /api/v1/tags/:id
+pub async fn rename_tag(
+ State(state): State,
+ auth: AuthSession,
+ Path(id): Path,
+ Json(payload): Json,
+) -> ApiResult> {
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
+ let user_id = user.id();
+
+ payload
+ .validate()
+ .map_err(|e| ApiError::validation(e.to_string()))?;
+
+ let service = TagService::new(state.tag_repo);
+
+ let tag = service.rename_tag(id, user_id, &payload.name).await?;
+
+ Ok(Json(TagResponse::from(tag)))
+}
+
/// Delete a tag
/// DELETE /api/v1/tags/:id
pub async fn delete_tag(
@@ -59,12 +95,16 @@ pub async fn delete_tag(
auth: AuthSession,
Path(id): Path,
) -> ApiResult {
- let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?;
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
let user_id = user.id();
let service = TagService::new(state.tag_repo);
-
+
service.delete_tag(id, user_id).await?;
-
+
Ok(StatusCode::NO_CONTENT)
}
diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs
index 40daab3..c8f240a 100644
--- a/notes-domain/src/services.rs
+++ b/notes-domain/src/services.rs
@@ -277,6 +277,40 @@ impl TagService {
self.tag_repo.delete(id).await
}
+
+ /// Rename a tag
+ pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult {
+ let new_name = new_name.trim().to_lowercase();
+ if new_name.is_empty() {
+ return Err(DomainError::validation("Tag name cannot be empty"));
+ }
+
+ // Find the existing tag
+ let mut tag = self
+ .tag_repo
+ .find_by_id(id)
+ .await?
+ .ok_or(DomainError::TagNotFound(id))?;
+
+ // Authorization check
+ if tag.user_id != user_id {
+ return Err(DomainError::unauthorized(
+ "Cannot rename another user's tag",
+ ));
+ }
+
+ // Check if new name already exists (and it's not the same tag)
+ if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? {
+ if existing.id != id {
+ return Err(DomainError::TagAlreadyExists(new_name));
+ }
+ }
+
+ // Update the name
+ tag.name = new_name;
+ self.tag_repo.save(&tag).await?;
+ Ok(tag)
+ }
}
/// Service for User operations (OIDC-ready)