mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-02-01 14:26:44 -06:00
All API routes now accept either UUID or slug as {ref} parameters. Routes auto-detect format and query accordingly, enabling human-readable URLs while maintaining stable UUID references.
720 lines
23 KiB
Rust
720 lines
23 KiB
Rust
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
|
use std::sync::Arc;
|
|
|
|
use crate::{auth, db, handlers::AddProjectTagRequest, state::AppState};
|
|
|
|
/// List all projects - returns filtered data based on auth status
|
|
pub async fn projects_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
) -> impl IntoResponse {
|
|
let is_admin = auth::check_session(&state, &jar).is_some();
|
|
|
|
if is_admin {
|
|
// Admin view: return all projects with tags and media
|
|
match db::get_all_projects_with_tags_admin(&state.pool).await {
|
|
Ok(projects_with_tags) => {
|
|
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
|
.into_iter()
|
|
.map(|(project, tags, media)| project.to_api_admin_project(tags, media))
|
|
.collect();
|
|
Json(response).into_response()
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch admin projects");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch projects"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
} else {
|
|
// Public view: return non-hidden projects with tags and media
|
|
match db::get_public_projects_with_tags(&state.pool).await {
|
|
Ok(projects_with_tags) => {
|
|
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
|
.into_iter()
|
|
.map(|(project, tags, media)| project.to_api_admin_project(tags, media))
|
|
.collect();
|
|
Json(response).into_response()
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch public projects");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch projects"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a single project by ref (UUID or slug)
|
|
pub async fn get_project_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path(ref_str): axum::extract::Path<String>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
) -> impl IntoResponse {
|
|
let is_admin = auth::check_session(&state, &jar).is_some();
|
|
|
|
match db::get_project_by_ref_with_tags(&state.pool, &ref_str).await {
|
|
Ok(Some((project, tags, media))) => {
|
|
// If project is hidden and user is not admin, return 404
|
|
if project.status == db::ProjectStatus::Hidden && !is_admin {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
|
}
|
|
Ok(None) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response(),
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch project"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new project (requires authentication)
|
|
pub async fn create_project_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
Json(payload): Json<db::CreateProjectRequest>,
|
|
) -> impl IntoResponse {
|
|
// Check auth
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
// Validate request
|
|
if payload.name.trim().is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Project name cannot be empty"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if payload.short_description.trim().is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Project short description cannot be empty"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Parse tag UUIDs
|
|
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
|
|
.tag_ids
|
|
.iter()
|
|
.map(|id| uuid::Uuid::parse_str(id))
|
|
.collect();
|
|
|
|
let tag_ids = match tag_ids {
|
|
Ok(ids) => ids,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Invalid tag UUID format"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Create project
|
|
let project = match db::create_project(
|
|
&state.pool,
|
|
&payload.name,
|
|
payload.slug.as_deref(),
|
|
&payload.short_description,
|
|
&payload.description,
|
|
payload.status,
|
|
payload.github_repo.as_deref(),
|
|
payload.demo_url.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(p) => p,
|
|
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
Json(serde_json::json!({
|
|
"error": "Conflict",
|
|
"message": "A project with this slug already exists"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to create project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to create project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Set tags
|
|
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
|
|
tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags");
|
|
}
|
|
|
|
// Fetch project with tags and media to return
|
|
let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id)
|
|
.await
|
|
{
|
|
Ok(Some(data)) => data,
|
|
Ok(None) => {
|
|
tracing::error!(project_id = %project.id, "Project not found after creation");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch created project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch created project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch created project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
tracing::info!(project_id = %project.id, project_name = %project.name, "Project created");
|
|
|
|
// Invalidate cached pages that display projects
|
|
state.isr_cache.invalidate("/").await;
|
|
|
|
(
|
|
StatusCode::CREATED,
|
|
Json(project.to_api_admin_project(tags, media)),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Update an existing project (requires authentication)
|
|
pub async fn update_project_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path(ref_str): axum::extract::Path<String>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
Json(payload): Json<db::UpdateProjectRequest>,
|
|
) -> impl IntoResponse {
|
|
// Check auth
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
// Find project by ref (UUID or slug)
|
|
let existing_project = match db::get_project_by_ref(&state.pool, &ref_str).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let project_id = existing_project.id;
|
|
|
|
// Validate request
|
|
if payload.name.trim().is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Project name cannot be empty"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if payload.short_description.trim().is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Project short description cannot be empty"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Parse tag UUIDs
|
|
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
|
|
.tag_ids
|
|
.iter()
|
|
.map(|id| uuid::Uuid::parse_str(id))
|
|
.collect();
|
|
|
|
let tag_ids = match tag_ids {
|
|
Ok(ids) => ids,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"error": "Validation error",
|
|
"message": "Invalid tag UUID format"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Update project
|
|
let project = match db::update_project(
|
|
&state.pool,
|
|
project_id,
|
|
&payload.name,
|
|
payload.slug.as_deref(),
|
|
&payload.short_description,
|
|
&payload.description,
|
|
payload.status,
|
|
payload.github_repo.as_deref(),
|
|
payload.demo_url.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(p) => p,
|
|
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
Json(serde_json::json!({
|
|
"error": "Conflict",
|
|
"message": "A project with this slug already exists"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to update project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to update project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Update tags (smart diff)
|
|
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
|
|
tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags");
|
|
}
|
|
|
|
// Fetch updated project with tags and media
|
|
let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id)
|
|
.await
|
|
{
|
|
Ok(Some(data)) => data,
|
|
Ok(None) => {
|
|
tracing::error!(project_id = %project.id, "Project not found after update");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch updated project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch updated project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch updated project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
tracing::info!(project_id = %project.id, project_name = %project.name, "Project updated");
|
|
|
|
// Invalidate cached pages that display projects
|
|
state.isr_cache.invalidate("/").await;
|
|
|
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
|
}
|
|
|
|
/// Delete a project (requires authentication)
|
|
pub async fn delete_project_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path(ref_str): axum::extract::Path<String>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
) -> impl IntoResponse {
|
|
// Check auth
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
// Fetch project before deletion to return it (lookup by UUID or slug)
|
|
let (project, tags, media) = match db::get_project_by_ref_with_tags(&state.pool, &ref_str).await
|
|
{
|
|
Ok(Some(data)) => data,
|
|
Ok(None) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project before deletion");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to delete project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Delete project (CASCADE handles tags and media)
|
|
match db::delete_project(&state.pool, project.id).await {
|
|
Ok(()) => {
|
|
tracing::info!(project_id = %project.id, project_name = %project.name, "Project deleted");
|
|
|
|
// Invalidate cached pages that display projects
|
|
state.isr_cache.invalidate("/").await;
|
|
|
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to delete project");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to delete project"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get admin stats (requires authentication)
|
|
pub async fn get_admin_stats_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
) -> impl IntoResponse {
|
|
// Check auth
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
match db::get_admin_stats(&state.pool).await {
|
|
Ok(stats) => Json(stats).into_response(),
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch admin stats");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch statistics"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get tags for a project
|
|
pub async fn get_project_tags_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path(ref_str): axum::extract::Path<String>,
|
|
) -> impl IntoResponse {
|
|
// Find project by ref (UUID or slug)
|
|
let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch project"
|
|
})),
|
|
)
|
|
.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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add a tag to a project (requires authentication)
|
|
pub async fn add_project_tag_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path(ref_str): axum::extract::Path<String>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
Json(payload): Json<AddProjectTagRequest>,
|
|
) -> impl IntoResponse {
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
// Find project by ref (UUID or slug)
|
|
let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch project"
|
|
})),
|
|
)
|
|
.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(()) => {
|
|
// Invalidate cached pages - tags affect how projects are displayed
|
|
state.isr_cache.invalidate("/").await;
|
|
|
|
(
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove a tag from a project (requires authentication)
|
|
pub async fn remove_project_tag_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::extract::Path((ref_str, tag_ref)): axum::extract::Path<(String, String)>,
|
|
jar: axum_extra::extract::CookieJar,
|
|
) -> impl IntoResponse {
|
|
if auth::check_session(&state, &jar).is_none() {
|
|
return auth::require_auth_response().into_response();
|
|
}
|
|
|
|
// Find project by ref (UUID or slug)
|
|
let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Not found",
|
|
"message": "Project not found"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "Failed to fetch project");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Internal server error",
|
|
"message": "Failed to fetch project"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Find tag by ref (UUID or slug)
|
|
let tag = match db::get_tag_by_ref(&state.pool, &tag_ref).await {
|
|
Ok(Some(t)) => t,
|
|
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::remove_tag_from_project(&state.pool, project.id, tag.id).await {
|
|
Ok(()) => {
|
|
// Invalidate cached pages - tags affect how projects are displayed
|
|
state.isr_cache.invalidate("/").await;
|
|
|
|
(
|
|
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()
|
|
}
|
|
}
|
|
}
|