feat: add comprehensive tagging system with cooccurrence tracking

- Add tags, project_tags, and tag_cooccurrence tables with proper indexes
- Implement full CRUD API endpoints for tag management
- Add tag association endpoints for projects with automatic cooccurrence updates
- Include related tags and project filtering by tag functionality
This commit is contained in:
2026-01-06 02:07:58 -06:00
parent b4c708335b
commit 045781f7a5
25 changed files with 1610 additions and 8 deletions
+110
View File
@@ -99,5 +99,115 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("✅ Seeded {} projects", project_count);
// Seed tags
let tags = vec![
("rust", "Rust"),
("python", "Python"),
("typescript", "TypeScript"),
("javascript", "JavaScript"),
("web", "Web"),
("cli", "CLI"),
("library", "Library"),
("game", "Game"),
("data-structures", "Data Structures"),
("algorithms", "Algorithms"),
("multiplayer", "Multiplayer"),
("config", "Config"),
];
let mut tag_ids = std::collections::HashMap::new();
for (slug, name) in tags {
let result = sqlx::query!(
r#"
INSERT INTO tags (slug, name)
VALUES ($1, $2)
RETURNING id
"#,
slug,
name
)
.fetch_one(&pool)
.await?;
tag_ids.insert(slug, result.id);
}
println!("✅ Seeded {} tags", tag_ids.len());
// Associate tags with projects
let project_tag_associations = vec![
// xevion-dev
("xevion-dev", vec!["rust", "web", "typescript"]),
// Contest
(
"contest",
vec!["python", "web", "algorithms", "data-structures"],
),
// Reforge
("reforge", vec!["rust", "library", "game"]),
// Algorithms
(
"algorithms",
vec!["python", "algorithms", "data-structures"],
),
// WordPlay
(
"wordplay",
vec!["typescript", "javascript", "web", "game", "multiplayer"],
),
// Dotfiles
("dotfiles", vec!["config", "cli"]),
];
let mut association_count = 0;
for (project_slug, tag_slugs) in project_tag_associations {
let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug)
.fetch_one(&pool)
.await?
.id;
for tag_slug in tag_slugs {
if let Some(&tag_id) = tag_ids.get(tag_slug) {
sqlx::query!(
"INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)",
project_id,
tag_id
)
.execute(&pool)
.await?;
association_count += 1;
}
}
}
println!("✅ Created {} project-tag associations", association_count);
// Recalculate tag cooccurrence
sqlx::query!("DELETE FROM tag_cooccurrence")
.execute(&pool)
.await?;
sqlx::query!(
r#"
INSERT INTO tag_cooccurrence (tag_a, tag_b, count)
SELECT
LEAST(t1.tag_id, t2.tag_id) as tag_a,
GREATEST(t1.tag_id, t2.tag_id) as tag_b,
COUNT(*)::int as count
FROM project_tags t1
JOIN project_tags t2 ON t1.project_id = t2.project_id
WHERE t1.tag_id < t2.tag_id
GROUP BY tag_a, tag_b
HAVING COUNT(*) > 0
"#
)
.execute(&pool)
.await?;
println!("✅ Recalculated tag cooccurrence");
Ok(())
}
+394
View File
@@ -31,6 +31,28 @@ pub struct DbProject {
pub updated_at: OffsetDateTime,
}
// Tag database models
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbTag {
pub id: Uuid,
pub slug: String,
pub name: String,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbProjectTag {
pub project_id: Uuid,
pub tag_id: Uuid,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbTagCooccurrence {
pub tag_a: Uuid,
pub tag_b: Uuid,
pub count: i32,
}
// API response types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiProjectLink {
@@ -42,6 +64,7 @@ pub struct ApiProjectLink {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiProject {
pub id: String,
pub slug: String,
pub name: String,
#[serde(rename = "shortDescription")]
pub short_description: String,
@@ -50,6 +73,45 @@ pub struct ApiProject {
pub links: Vec<ApiProjectLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiTag {
pub id: String,
pub slug: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiProjectWithTags {
#[serde(flatten)]
pub project: ApiProject,
pub tags: Vec<ApiTag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiTagWithCount {
#[serde(flatten)]
pub tag: ApiTag,
pub project_count: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiRelatedTag {
#[serde(flatten)]
pub tag: ApiTag,
pub cooccurrence_count: i32,
}
impl DbTag {
/// Convert database tag to API response format
pub fn to_api_tag(&self) -> ApiTag {
ApiTag {
id: self.id.to_string(),
slug: self.slug.clone(),
name: self.name.clone(),
}
}
}
impl DbProject {
/// Convert database project to API response format
pub fn to_api_project(&self) -> ApiProject {
@@ -71,6 +133,7 @@ impl DbProject {
ApiProject {
id: self.id.to_string(),
slug: self.slug.clone(),
name: self.title.clone(),
short_description: self.description.clone(),
icon: self.icon.clone(),
@@ -121,3 +184,334 @@ pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> {
.await
.map(|_| ())
}
// Helper function: slugify text
pub fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else if c.is_whitespace() || c == '-' || c == '_' {
'-'
} else {
'\0'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
// Tag CRUD queries
pub async fn create_tag(
pool: &PgPool,
name: &str,
slug_override: Option<&str>,
) -> Result<DbTag, sqlx::Error> {
let slug = slug_override
.map(|s| slugify(s))
.unwrap_or_else(|| slugify(name));
sqlx::query_as!(
DbTag,
r#"
INSERT INTO tags (slug, name)
VALUES ($1, $2)
RETURNING id, slug, name, created_at
"#,
slug,
name
)
.fetch_one(pool)
.await
}
pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result<Option<DbTag>, sqlx::Error> {
sqlx::query_as!(
DbTag,
r#"
SELECT id, slug, name, created_at
FROM tags
WHERE id = $1
"#,
id
)
.fetch_optional(pool)
.await
}
pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbTag>, sqlx::Error> {
sqlx::query_as!(
DbTag,
r#"
SELECT id, slug, name, created_at
FROM tags
WHERE slug = $1
"#,
slug
)
.fetch_optional(pool)
.await
}
pub async fn get_all_tags(pool: &PgPool) -> Result<Vec<DbTag>, sqlx::Error> {
sqlx::query_as!(
DbTag,
r#"
SELECT id, slug, name, created_at
FROM tags
ORDER BY name ASC
"#
)
.fetch_all(pool)
.await
}
pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result<Vec<(DbTag, i32)>, sqlx::Error> {
let rows = sqlx::query!(
r#"
SELECT
t.id,
t.slug,
t.name,
t.created_at,
COUNT(pt.project_id)::int as "project_count!"
FROM tags t
LEFT JOIN project_tags pt ON t.id = pt.tag_id
GROUP BY t.id, t.slug, t.name, t.created_at
ORDER BY t.name ASC
"#
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| {
let tag = DbTag {
id: row.id,
slug: row.slug,
name: row.name,
created_at: row.created_at,
};
(tag, row.project_count)
})
.collect())
}
pub async fn update_tag(
pool: &PgPool,
id: Uuid,
name: &str,
slug_override: Option<&str>,
) -> Result<DbTag, sqlx::Error> {
let slug = slug_override
.map(|s| slugify(s))
.unwrap_or_else(|| slugify(name));
sqlx::query_as!(
DbTag,
r#"
UPDATE tags
SET slug = $2, name = $3
WHERE id = $1
RETURNING id, slug, name, created_at
"#,
id,
slug,
name
)
.fetch_one(pool)
.await
}
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 tag_exists_by_name(pool: &PgPool, name: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
r#"
SELECT EXISTS(SELECT 1 FROM tags WHERE LOWER(name) = LOWER($1)) as "exists!"
"#,
name
)
.fetch_one(pool)
.await?;
Ok(result.exists)
}
pub async fn tag_exists_by_slug(pool: &PgPool, slug: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
r#"
SELECT EXISTS(SELECT 1 FROM tags WHERE slug = $1) as "exists!"
"#,
slug
)
.fetch_one(pool)
.await?;
Ok(result.exists)
}
// Project-Tag association queries
pub async fn add_tag_to_project(
pool: &PgPool,
project_id: Uuid,
tag_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO project_tags (project_id, tag_id)
VALUES ($1, $2)
ON CONFLICT (project_id, tag_id) DO NOTHING
"#,
project_id,
tag_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn remove_tag_from_project(
pool: &PgPool,
project_id: Uuid,
tag_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"DELETE FROM project_tags WHERE project_id = $1 AND tag_id = $2",
project_id,
tag_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_tags_for_project(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<DbTag>, sqlx::Error> {
sqlx::query_as!(
DbTag,
r#"
SELECT t.id, t.slug, t.name, t.created_at
FROM tags t
JOIN project_tags pt ON t.id = pt.tag_id
WHERE pt.project_id = $1
ORDER BY t.name ASC
"#,
project_id
)
.fetch_all(pool)
.await
}
pub async fn get_projects_for_tag(
pool: &PgPool,
tag_id: Uuid,
) -> Result<Vec<DbProject>, sqlx::Error> {
sqlx::query_as!(
DbProject,
r#"
SELECT
p.id,
p.slug,
p.title,
p.description,
p.status as "status: ProjectStatus",
p.github_repo,
p.demo_url,
p.priority,
p.icon,
p.last_github_activity,
p.created_at,
p.updated_at
FROM projects p
JOIN project_tags pt ON p.id = pt.project_id
WHERE pt.tag_id = $1
ORDER BY p.priority DESC, p.created_at DESC
"#,
tag_id
)
.fetch_all(pool)
.await
}
// Tag cooccurrence queries
pub async fn recalculate_tag_cooccurrence(pool: &PgPool) -> Result<(), sqlx::Error> {
// Delete existing cooccurrence data
sqlx::query!("DELETE FROM tag_cooccurrence")
.execute(pool)
.await?;
// Calculate and insert new cooccurrence data
sqlx::query!(
r#"
INSERT INTO tag_cooccurrence (tag_a, tag_b, count)
SELECT
LEAST(t1.tag_id, t2.tag_id) as tag_a,
GREATEST(t1.tag_id, t2.tag_id) as tag_b,
COUNT(*)::int as count
FROM project_tags t1
JOIN project_tags t2 ON t1.project_id = t2.project_id
WHERE t1.tag_id < t2.tag_id
GROUP BY tag_a, tag_b
HAVING COUNT(*) > 0
"#
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_related_tags(
pool: &PgPool,
tag_id: Uuid,
limit: i64,
) -> Result<Vec<(DbTag, i32)>, sqlx::Error> {
let rows = sqlx::query!(
r#"
SELECT
t.id,
t.slug,
t.name,
t.created_at,
tc.count
FROM tag_cooccurrence tc
JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)
WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1
ORDER BY tc.count DESC, t.name ASC
LIMIT $2
"#,
tag_id,
limit
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| {
let tag = DbTag {
id: row.id,
slug: row.slug,
name: row.name,
created_at: row.created_at,
};
(tag, row.count)
})
.collect())
}
+459 -5
View File
@@ -86,12 +86,13 @@ async fn main() {
.expect("Failed to connect to database");
// Run migrations on startup
sqlx::migrate!()
.run(&pool)
.await
.expect("Failed to run database migrations");
tracing::info!("Running database migrations...");
sqlx::migrate!().run(&pool).await.unwrap_or_else(|e| {
tracing::error!(error = %e, "Migration failed");
std::process::exit(1);
});
tracing::info!("Database connected and migrations applied");
tracing::info!("Migrations applied successfully");
if args.listen.is_empty() {
eprintln!("Error: At least one --listen address is required");
@@ -315,6 +316,30 @@ fn api_routes() -> Router<Arc<AppState>> {
axum::routing::get(health_handler).head(health_handler),
)
.route("/projects", axum::routing::get(projects_handler))
.route(
"/projects/{id}/tags",
axum::routing::get(get_project_tags_handler).post(add_project_tag_handler),
)
.route(
"/projects/{id}/tags/{tag_id}",
axum::routing::delete(remove_project_tag_handler),
)
.route(
"/tags",
axum::routing::get(list_tags_handler).post(create_tag_handler),
)
.route(
"/tags/{slug}",
axum::routing::get(get_tag_handler).put(update_tag_handler),
)
.route(
"/tags/{slug}/related",
axum::routing::get(get_related_tags_handler),
)
.route(
"/tags/recalculate-cooccurrence",
axum::routing::post(recalculate_cooccurrence_handler),
)
.fallback(api_404_and_method_handler)
}
@@ -466,6 +491,435 @@ async fn projects_handler(State(state): State<Arc<AppState>>) -> impl IntoRespon
}
}
// Tag API handlers
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_all_tags_with_counts(&state.pool).await {
Ok(tags_with_counts) => {
let api_tags: Vec<db::ApiTagWithCount> = tags_with_counts
.into_iter()
.map(|(tag, count)| db::ApiTagWithCount {
tag: tag.to_api_tag(),
project_count: count,
})
.collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
#[derive(serde::Deserialize)]
struct CreateTagRequest {
name: String,
slug: Option<String>,
}
async fn create_tag_handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTagRequest>,
) -> impl IntoResponse {
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
match db::create_tag(&state.pool, &payload.name, payload.slug.as_deref()).await {
Ok(tag) => (StatusCode::CREATED, Json(tag.to_api_tag())).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to create tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create tag"
})),
)
.into_response()
}
}
}
async fn get_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await {
Ok(projects) => {
let response = serde_json::json!({
"tag": tag.to_api_tag(),
"projects": projects.into_iter().map(|p| p.to_api_project()).collect::<Vec<_>>()
});
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch projects for tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
},
Ok(None) => (
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");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response()
}
}
}
#[derive(serde::Deserialize)]
struct UpdateTagRequest {
name: String,
slug: Option<String>,
}
async fn update_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
Json(payload): Json<UpdateTagRequest>,
) -> impl IntoResponse {
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
let tag = match db::get_tag_by_slug(&state.pool, &slug).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");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::update_tag(&state.pool, tag.id, &payload.name, payload.slug.as_deref()).await {
Ok(updated_tag) => Json(updated_tag.to_api_tag()).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to update tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update tag"
})),
)
.into_response()
}
}
}
async fn get_related_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
let tag = match db::get_tag_by_slug(&state.pool, &slug).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");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::get_related_tags(&state.pool, tag.id, 10).await {
Ok(related_tags) => {
let api_related_tags: Vec<db::ApiRelatedTag> = related_tags
.into_iter()
.map(|(tag, count)| db::ApiRelatedTag {
tag: tag.to_api_tag(),
cooccurrence_count: count,
})
.collect();
Json(api_related_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch related tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch related tags"
})),
)
.into_response()
}
}
}
async fn recalculate_cooccurrence_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::recalculate_tag_cooccurrence(&state.pool).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag cooccurrence recalculated successfully"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to recalculate cooccurrence");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to recalculate cooccurrence"
})),
)
.into_response()
}
}
}
// Project-Tag association handlers
async fn get_project_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::get_tags_for_project(&state.pool, project_id).await {
Ok(tags) => {
let api_tags: Vec<db::ApiTag> = tags.into_iter().map(|t| t.to_api_tag()).collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags for project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
#[derive(serde::Deserialize)]
struct AddProjectTagRequest {
tag_id: String,
}
async fn add_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
Json(payload): Json<AddProjectTagRequest>,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&payload.tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::add_tag_to_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::CREATED,
Json(serde_json::json!({
"message": "Tag added to project"
})),
)
.into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_foreign_key_violation() => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project or tag not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to add tag to project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to add tag to project"
})),
)
.into_response()
}
}
}
async fn remove_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::remove_tag_from_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag removed from project"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to remove tag from project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to remove tag from project"
})),
)
.into_response()
}
}
}
fn should_tarpit(state: &TarpitState, path: &str) -> bool {
state.config.enabled && is_malicious_path(path)
}