feat: add tag deletion endpoint with CLI support

This commit is contained in:
2026-01-15 00:01:09 -06:00
parent c4a08a1477
commit 231a7680ac
6 changed files with 113 additions and 1 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tags WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824"
}
+22
View File
@@ -35,6 +35,7 @@ pub async fn run(
icon, icon,
color, color,
} => update(client, &slug, name, new_slug, icon, color, json).await, } => update(client, &slug, name, new_slug, icon, color, json).await,
TagsCommand::Delete { reference } => delete(client, &reference, json).await,
} }
} }
@@ -171,3 +172,24 @@ async fn update(
Ok(()) Ok(())
} }
/// Delete a tag
async fn delete(
client: ApiClient,
reference: &str,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.delete_auth(&format!("/api/tags/{}", reference))
.await?;
let response = check_response(response).await?;
let deleted: ApiTag = response.json().await?;
if json {
println!("{}", serde_json::to_string_pretty(&deleted)?);
} else {
output::success(&format!("Deleted tag: {}", deleted.name));
}
Ok(())
}
+7
View File
@@ -229,6 +229,13 @@ pub enum TagsCommand {
#[arg(long)] #[arg(long)]
color: Option<String>, color: Option<String>,
}, },
/// Delete a tag
Delete {
/// Tag slug or UUID
#[arg(name = "ref")]
reference: String,
},
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
+7
View File
@@ -120,6 +120,13 @@ pub async fn get_tag_by_ref(pool: &PgPool, ref_str: &str) -> Result<Option<DbTag
} }
} }
pub async fn delete_tag(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM tags WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result<Vec<(DbTag, i32)>, sqlx::Error> { pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result<Vec<(DbTag, i32)>, sqlx::Error> {
let rows = sqlx::query!( let rows = sqlx::query!(
r#" r#"
+60
View File
@@ -252,6 +252,66 @@ pub async fn update_tag_handler(
} }
} }
/// Delete a tag (requires authentication)
pub async fn delete_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Fetch tag before deletion to return it
let tag = match db::get_tag_by_ref(&state.pool, &ref_str).await {
Ok(Some(tag)) => tag,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag before deletion");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete tag"
})),
)
.into_response();
}
};
// Delete tag (CASCADE handles project_tags and tag_cooccurrence)
match db::delete_tag(&state.pool, tag.id).await {
Ok(()) => {
tracing::info!(tag_id = %tag.id, tag_name = %tag.name, "Tag deleted");
// Invalidate cached pages - tags appear on project pages
state.isr_cache.invalidate("/").await;
Json(tag.to_api_tag()).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to delete tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete tag"
})),
)
.into_response()
}
}
}
/// Get related tags by cooccurrence /// Get related tags by cooccurrence
pub async fn get_related_tags_handler( pub async fn get_related_tags_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
+3 -1
View File
@@ -65,7 +65,9 @@ pub fn api_routes() -> Router<Arc<AppState>> {
) )
.route( .route(
"/tags/{ref}", "/tags/{ref}",
get(handlers::get_tag_handler).put(handlers::update_tag_handler), get(handlers::get_tag_handler)
.put(handlers::update_tag_handler)
.delete(handlers::delete_tag_handler),
) )
.route( .route(
"/tags/{ref}/related", "/tags/{ref}/related",