diff --git a/.sqlx/query-19a91b6faba900c4da16992c888b0c96403cb15b4a66bad50375ed6395b6ec7d.json b/.sqlx/query-19a91b6faba900c4da16992c888b0c96403cb15b4a66bad50375ed6395b6ec7d.json new file mode 100644 index 0000000..336ed2e --- /dev/null +++ b/.sqlx/query-19a91b6faba900c4da16992c888b0c96403cb15b4a66bad50375ed6395b6ec7d.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n slug,\n name,\n short_description,\n description,\n status as \"status: ProjectStatus\",\n github_repo,\n demo_url,\n last_github_activity,\n created_at\n FROM projects\n WHERE slug = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "19a91b6faba900c4da16992c888b0c96403cb15b4a66bad50375ed6395b6ec7d" +} diff --git a/.sqlx/query-d9432498283c5b2788d741af04495b34101c2d5d270f6f153b2acf6ad1c30438.json b/.sqlx/query-d9432498283c5b2788d741af04495b34101c2d5d270f6f153b2acf6ad1c30438.json new file mode 100644 index 0000000..f67637d --- /dev/null +++ b/.sqlx/query-d9432498283c5b2788d741af04495b34101c2d5d270f6f153b2acf6ad1c30438.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, name, icon, color\n FROM tags\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "color", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "d9432498283c5b2788d741af04495b34101c2d5d270f6f153b2acf6ad1c30438" +} diff --git a/src/db/projects.rs b/src/db/projects.rs index ca746f3..f393f99 100644 --- a/src/db/projects.rs +++ b/src/db/projects.rs @@ -274,6 +274,63 @@ pub async fn get_project_by_id_with_tags( } } +/// Get single project by slug +pub async fn get_project_by_slug( + pool: &PgPool, + slug: &str, +) -> Result, sqlx::Error> { + query_as!( + DbProject, + r#" + SELECT + id, + slug, + name, + short_description, + description, + status as "status: ProjectStatus", + github_repo, + demo_url, + last_github_activity, + created_at + FROM projects + WHERE slug = $1 + "#, + slug + ) + .fetch_optional(pool) + .await +} + +/// Get a project by either UUID or slug (auto-detects format) +pub async fn get_project_by_ref( + pool: &PgPool, + ref_str: &str, +) -> Result, sqlx::Error> { + if let Ok(uuid) = Uuid::parse_str(ref_str) { + get_project_by_id(pool, uuid).await + } else { + get_project_by_slug(pool, ref_str).await + } +} + +/// Get a project by ref (UUID or slug) with tags and media +pub async fn get_project_by_ref_with_tags( + pool: &PgPool, + ref_str: &str, +) -> Result, Vec)>, sqlx::Error> { + let project = get_project_by_ref(pool, ref_str).await?; + + match project { + Some(p) => { + let tags = get_tags_for_project(pool, p.id).await?; + let media = get_media_for_project(pool, p.id).await?; + Ok(Some((p, tags, media))) + } + None => Ok(None), + } +} + /// Create project (without tags - tags handled separately) #[allow(clippy::too_many_arguments)] pub async fn create_project( diff --git a/src/db/tags.rs b/src/db/tags.rs index 4c5b1dc..5fe32e1 100644 --- a/src/db/tags.rs +++ b/src/db/tags.rs @@ -97,6 +97,29 @@ pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result, .await } +pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + DbTag, + r#" + SELECT id, slug, name, icon, color + FROM tags + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await +} + +/// Get a tag by either UUID or slug (auto-detects format) +pub async fn get_tag_by_ref(pool: &PgPool, ref_str: &str) -> Result, sqlx::Error> { + if let Ok(uuid) = Uuid::parse_str(ref_str) { + get_tag_by_id(pool, uuid).await + } else { + get_tag_by_slug(pool, ref_str).await + } +} + pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result, sqlx::Error> { let rows = sqlx::query!( r#" diff --git a/src/handlers/media.rs b/src/handlers/media.rs index e2b54df..88c1d1b 100644 --- a/src/handlers/media.rs +++ b/src/handlers/media.rs @@ -24,7 +24,7 @@ pub struct ReorderMediaRequest { /// Images are processed into variants (thumb, medium, full) and uploaded to R2. pub async fn upload_media_handler( State(state): State>, - axum::extract::Path(project_id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, mut multipart: Multipart, ) -> impl IntoResponse { @@ -33,22 +33,9 @@ pub async fn upload_media_handler( return auth::require_auth_response().into_response(); } - let project_id = match Uuid::parse_str(&project_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(); - } - }; - - // Verify project exists - match db::get_project_by_id(&state.pool, project_id).await { + // 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, @@ -60,7 +47,7 @@ pub async fn upload_media_handler( .into_response(); } Err(err) => { - tracing::error!(error = %err, "Failed to check project existence"); + tracing::error!(error = %err, "Failed to fetch project"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ @@ -70,8 +57,9 @@ pub async fn upload_media_handler( ) .into_response(); } - Ok(Some(_)) => {} - } + }; + + let project_id = project.id; // Get R2 client let r2 = match R2Client::get().await { @@ -393,24 +381,11 @@ async fn upload_image_variants( /// Get all media for a project pub async fn get_project_media_handler( State(state): State>, - axum::extract::Path(project_id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, ) -> impl IntoResponse { - let project_id = match Uuid::parse_str(&project_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(); - } - }; - - // Verify project exists - match db::get_project_by_id(&state.pool, project_id).await { + // 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, @@ -422,7 +397,7 @@ pub async fn get_project_media_handler( .into_response(); } Err(err) => { - tracing::error!(error = %err, "Failed to check project existence"); + tracing::error!(error = %err, "Failed to fetch project"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ @@ -432,17 +407,16 @@ pub async fn get_project_media_handler( ) .into_response(); } - Ok(Some(_)) => {} - } + }; - match db::get_media_for_project(&state.pool, project_id).await { + match db::get_media_for_project(&state.pool, project.id).await { Ok(media) => { let response: Vec = media.into_iter().map(|m| m.to_api_media()).collect(); Json(response).into_response() } Err(err) => { - tracing::error!(error = %err, project_id = %project_id, "Failed to fetch project media"); + tracing::error!(error = %err, project_id = %project.id, "Failed to fetch project media"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ @@ -458,7 +432,7 @@ pub async fn get_project_media_handler( /// Delete a media item (requires authentication) pub async fn delete_media_handler( State(state): State>, - axum::extract::Path((project_id, media_id)): axum::extract::Path<(String, String)>, + axum::extract::Path((ref_str, media_id)): axum::extract::Path<(String, String)>, jar: axum_extra::extract::CookieJar, ) -> impl IntoResponse { // Check auth @@ -466,14 +440,26 @@ pub async fn delete_media_handler( return auth::require_auth_response().into_response(); } - let project_id = match Uuid::parse_str(&project_id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(); @@ -497,7 +483,7 @@ pub async fn delete_media_handler( // Get media first to verify it belongs to the project match db::get_media_by_id(&state.pool, media_id).await { Ok(Some(media)) => { - if media.project_id != project_id { + if media.project_id != project.id { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -538,7 +524,7 @@ pub async fn delete_media_handler( Ok(Some(deleted)) => { tracing::info!( media_id = %media_id, - project_id = %project_id, + project_id = %project.id, r2_base_path = %deleted.r2_base_path, "Media deleted from database" ); @@ -594,7 +580,7 @@ pub async fn delete_media_handler( /// Reorder media items for a project (requires authentication) pub async fn reorder_media_handler( State(state): State>, - axum::extract::Path(project_id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { @@ -603,14 +589,26 @@ pub async fn reorder_media_handler( return auth::require_auth_response().into_response(); } - let project_id = match Uuid::parse_str(&project_id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(); @@ -638,37 +636,11 @@ pub async fn reorder_media_handler( } }; - // Verify project exists - match db::get_project_by_id(&state.pool, project_id).await { - 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 check project existence"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Internal server error", - "message": "Failed to verify project" - })), - ) - .into_response(); - } - Ok(Some(_)) => {} - } - // Reorder media - match db::reorder_media(&state.pool, project_id, &media_ids).await { + match db::reorder_media(&state.pool, project.id, &media_ids).await { Ok(()) => { // Fetch updated media list - match db::get_media_for_project(&state.pool, project_id).await { + match db::get_media_for_project(&state.pool, project.id).await { Ok(media) => { // Invalidate cache since project data changed state.isr_cache.invalidate("/").await; diff --git a/src/handlers/projects.rs b/src/handlers/projects.rs index de8a802..3fd7267 100644 --- a/src/handlers/projects.rs +++ b/src/handlers/projects.rs @@ -57,29 +57,15 @@ pub async fn projects_handler( } } -/// Get a single project by ID +/// Get a single project by ref (UUID or slug) pub async fn get_project_handler( State(state): State>, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, ) -> 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 is_admin = auth::check_session(&state, &jar).is_some(); - match db::get_project_by_id_with_tags(&state.pool, project_id).await { + 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 { @@ -258,7 +244,7 @@ pub async fn create_project_handler( /// Update an existing project (requires authentication) pub async fn update_project_handler( State(state): State>, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { @@ -267,37 +253,33 @@ pub async fn update_project_handler( return auth::require_auth_response().into_response(); } - // Parse project ID - let project_id = match uuid::Uuid::parse_str(&id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(); } }; - // Validate exists - if db::get_project_by_id(&state.pool, project_id) - .await - .ok() - .flatten() - .is_none() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "Not found", - "message": "Project not found" - })), - ) - .into_response(); - } + let project_id = existing_project.id; // Validate request if payload.name.trim().is_empty() { @@ -426,7 +408,7 @@ pub async fn update_project_handler( /// Delete a project (requires authentication) pub async fn delete_project_handler( State(state): State>, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, ) -> impl IntoResponse { // Check auth @@ -434,52 +416,37 @@ pub async fn delete_project_handler( return auth::require_auth_response().into_response(); } - // Parse project ID - let project_id = match uuid::Uuid::parse_str(&id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(); } }; - // Fetch project before deletion to return it - let (project, tags, media) = - match db::get_project_by_id_with_tags(&state.pool, project_id).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 { + match db::delete_project(&state.pool, project.id).await { Ok(()) => { - tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted"); + tracing::info!(project_id = %project.id, project_name = %project.name, "Project deleted"); // Invalidate cached pages that display projects state.isr_cache.invalidate("/").await; @@ -529,23 +496,35 @@ pub async fn get_admin_stats_handler( /// Get tags for a project pub async fn get_project_tags_handler( State(state): State>, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, ) -> impl IntoResponse { - let project_id = match uuid::Uuid::parse_str(&id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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 { + match db::get_tags_for_project(&state.pool, project.id).await { Ok(tags) => { let api_tags: Vec = tags.into_iter().map(|t| t.to_api_tag()).collect(); Json(api_tags).into_response() @@ -567,21 +546,34 @@ pub async fn get_project_tags_handler( /// Add a tag to a project (requires authentication) pub async fn add_project_tag_handler( State(state): State>, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { if auth::check_session(&state, &jar).is_none() { return auth::require_auth_response().into_response(); } - let project_id = match uuid::Uuid::parse_str(&id) { - Ok(id) => id, - Err(_) => { + + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(); @@ -602,7 +594,7 @@ pub async fn add_project_tag_handler( } }; - match db::add_tag_to_project(&state.pool, project_id, tag_id).await { + 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; @@ -640,41 +632,66 @@ pub async fn add_project_tag_handler( /// Remove a tag from a project (requires authentication) pub async fn remove_project_tag_handler( State(state): State>, - axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>, + 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(); } - let project_id = match uuid::Uuid::parse_str(&id) { - Ok(id) => id, - Err(_) => { + + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid project ID", - "message": "Project ID must be a valid UUID" + "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(&tag_id) { - Ok(id) => id, - Err(_) => { + // 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::BAD_REQUEST, + StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "Invalid tag ID", - "message": "Tag ID must be a valid UUID" + "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 { + 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; diff --git a/src/handlers/tags.rs b/src/handlers/tags.rs index c492cca..329e5a2 100644 --- a/src/handlers/tags.rs +++ b/src/handlers/tags.rs @@ -106,12 +106,12 @@ pub async fn create_tag_handler( } } -/// Get a tag by slug with associated projects +/// Get a tag by ref (UUID or slug) with associated projects pub async fn get_tag_handler( State(state): State>, - axum::extract::Path(slug): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, ) -> impl IntoResponse { - match db::get_tag_by_slug(&state.pool, &slug).await { + match db::get_tag_by_ref(&state.pool, &ref_str).await { Ok(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await { Ok(projects) => { let response = serde_json::json!({ @@ -157,7 +157,7 @@ pub async fn get_tag_handler( /// Update a tag (requires authentication) pub async fn update_tag_handler( State(state): State>, - axum::extract::Path(slug): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { @@ -189,7 +189,7 @@ pub async fn update_tag_handler( .into_response(); } - let tag = match db::get_tag_by_slug(&state.pool, &slug).await { + let tag = match db::get_tag_by_ref(&state.pool, &ref_str).await { Ok(Some(tag)) => tag, Ok(None) => { return ( @@ -255,9 +255,9 @@ pub async fn update_tag_handler( /// Get related tags by cooccurrence pub async fn get_related_tags_handler( State(state): State>, - axum::extract::Path(slug): axum::extract::Path, + axum::extract::Path(ref_str): axum::extract::Path, ) -> impl IntoResponse { - let tag = match db::get_tag_by_slug(&state.pool, &slug).await { + let tag = match db::get_tag_by_ref(&state.pool, &ref_str).await { Ok(Some(tag)) => tag, Ok(None) => { return ( diff --git a/src/routes.rs b/src/routes.rs index 0db5376..934ac7c 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -24,49 +24,51 @@ pub fn api_routes() -> Router> { .route("/session", get(handlers::api_session_handler)) // Projects - GET is public (shows all for admin, only non-hidden for public) // POST/PUT/DELETE require authentication + // {ref} accepts either UUID or slug .route( "/projects", get(handlers::projects_handler).post(handlers::create_project_handler), ) .route( - "/projects/{id}", + "/projects/{ref}", get(handlers::get_project_handler) .put(handlers::update_project_handler) .delete(handlers::delete_project_handler), ) // Project tags - authentication checked in handlers .route( - "/projects/{id}/tags", + "/projects/{ref}/tags", get(handlers::get_project_tags_handler).post(handlers::add_project_tag_handler), ) .route( - "/projects/{id}/tags/{tag_id}", + "/projects/{ref}/tags/{tag_ref}", delete(handlers::remove_project_tag_handler), ) // Project media - GET is public, POST/PUT/DELETE require authentication .route( - "/projects/{id}/media", + "/projects/{ref}/media", get(handlers::get_project_media_handler).post(handlers::upload_media_handler), ) .route( - "/projects/{id}/media/reorder", + "/projects/{ref}/media/reorder", put(handlers::reorder_media_handler), ) .route( - "/projects/{id}/media/{media_id}", + "/projects/{ref}/media/{media_id}", delete(handlers::delete_media_handler), ) // Tags - authentication checked in handlers + // {ref} accepts either UUID or slug .route( "/tags", get(handlers::list_tags_handler).post(handlers::create_tag_handler), ) .route( - "/tags/{slug}", + "/tags/{ref}", get(handlers::get_tag_handler).put(handlers::update_tag_handler), ) .route( - "/tags/{slug}/related", + "/tags/{ref}/related", get(handlers::get_related_tags_handler), ) .route(