feat: add universal slug/UUID support for project and tag endpoints

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.
This commit is contained in:
2026-01-14 23:56:03 -06:00
parent 6ad6da13ee
commit c4a08a1477
8 changed files with 414 additions and 209 deletions
@@ -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"
}
@@ -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"
}
+57
View File
@@ -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<Option<DbProject>, 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<Option<DbProject>, 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<Option<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, 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) /// Create project (without tags - tags handled separately)
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn create_project( pub async fn create_project(
+23
View File
@@ -97,6 +97,29 @@ pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbTag>,
.await .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, 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<Option<DbTag>, 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<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#"
+58 -86
View File
@@ -24,7 +24,7 @@ pub struct ReorderMediaRequest {
/// Images are processed into variants (thumb, medium, full) and uploaded to R2. /// Images are processed into variants (thumb, medium, full) and uploaded to R2.
pub async fn upload_media_handler( pub async fn upload_media_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
mut multipart: Multipart, mut multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -33,22 +33,9 @@ pub async fn upload_media_handler(
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
let project_id = match Uuid::parse_str(&project_id) { // Find project by ref (UUID or slug)
Ok(id) => id, let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Err(_) => { Ok(Some(p)) => p,
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 {
Ok(None) => { Ok(None) => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -60,7 +47,7 @@ pub async fn upload_media_handler(
.into_response(); .into_response();
} }
Err(err) => { Err(err) => {
tracing::error!(error = %err, "Failed to check project existence"); tracing::error!(error = %err, "Failed to fetch project");
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -70,8 +57,9 @@ pub async fn upload_media_handler(
) )
.into_response(); .into_response();
} }
Ok(Some(_)) => {} };
}
let project_id = project.id;
// Get R2 client // Get R2 client
let r2 = match R2Client::get().await { let r2 = match R2Client::get().await {
@@ -393,24 +381,11 @@ async fn upload_image_variants(
/// Get all media for a project /// Get all media for a project
pub async fn get_project_media_handler( pub async fn get_project_media_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let project_id = match Uuid::parse_str(&project_id) { // Find project by ref (UUID or slug)
Ok(id) => id, let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Err(_) => { Ok(Some(p)) => p,
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 {
Ok(None) => { Ok(None) => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -422,7 +397,7 @@ pub async fn get_project_media_handler(
.into_response(); .into_response();
} }
Err(err) => { Err(err) => {
tracing::error!(error = %err, "Failed to check project existence"); tracing::error!(error = %err, "Failed to fetch project");
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -432,17 +407,16 @@ pub async fn get_project_media_handler(
) )
.into_response(); .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) => { Ok(media) => {
let response: Vec<db::ApiProjectMedia> = let response: Vec<db::ApiProjectMedia> =
media.into_iter().map(|m| m.to_api_media()).collect(); media.into_iter().map(|m| m.to_api_media()).collect();
Json(response).into_response() Json(response).into_response()
} }
Err(err) => { 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, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -458,7 +432,7 @@ pub async fn get_project_media_handler(
/// Delete a media item (requires authentication) /// Delete a media item (requires authentication)
pub async fn delete_media_handler( pub async fn delete_media_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
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, jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Check auth // Check auth
@@ -466,14 +440,26 @@ pub async fn delete_media_handler(
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
let project_id = match Uuid::parse_str(&project_id) { // Find project by ref (UUID or slug)
Ok(id) => id, let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Err(_) => { Ok(Some(p)) => p,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .into_response();
@@ -497,7 +483,7 @@ pub async fn delete_media_handler(
// Get media first to verify it belongs to the project // Get media first to verify it belongs to the project
match db::get_media_by_id(&state.pool, media_id).await { match db::get_media_by_id(&state.pool, media_id).await {
Ok(Some(media)) => { Ok(Some(media)) => {
if media.project_id != project_id { if media.project_id != project.id {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -538,7 +524,7 @@ pub async fn delete_media_handler(
Ok(Some(deleted)) => { Ok(Some(deleted)) => {
tracing::info!( tracing::info!(
media_id = %media_id, media_id = %media_id,
project_id = %project_id, project_id = %project.id,
r2_base_path = %deleted.r2_base_path, r2_base_path = %deleted.r2_base_path,
"Media deleted from database" "Media deleted from database"
); );
@@ -594,7 +580,7 @@ pub async fn delete_media_handler(
/// Reorder media items for a project (requires authentication) /// Reorder media items for a project (requires authentication)
pub async fn reorder_media_handler( pub async fn reorder_media_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
Json(payload): Json<ReorderMediaRequest>, Json(payload): Json<ReorderMediaRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -603,14 +589,26 @@ pub async fn reorder_media_handler(
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
let project_id = match Uuid::parse_str(&project_id) { // Find project by ref (UUID or slug)
Ok(id) => id, let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Err(_) => { Ok(Some(p)) => p,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .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 // 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(()) => { Ok(()) => {
// Fetch updated media list // 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) => { Ok(media) => {
// Invalidate cache since project data changed // Invalidate cache since project data changed
state.isr_cache.invalidate("/").await; state.isr_cache.invalidate("/").await;
+125 -108
View File
@@ -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( pub async fn get_project_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse { ) -> 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(); 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))) => { Ok(Some((project, tags, media))) => {
// If project is hidden and user is not admin, return 404 // If project is hidden and user is not admin, return 404
if project.status == db::ProjectStatus::Hidden && !is_admin { if project.status == db::ProjectStatus::Hidden && !is_admin {
@@ -258,7 +244,7 @@ pub async fn create_project_handler(
/// Update an existing project (requires authentication) /// Update an existing project (requires authentication)
pub async fn update_project_handler( pub async fn update_project_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateProjectRequest>, Json(payload): Json<db::UpdateProjectRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -267,37 +253,33 @@ pub async fn update_project_handler(
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
// Parse project ID // Find project by ref (UUID or slug)
let project_id = match uuid::Uuid::parse_str(&id) { let existing_project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Ok(id) => id, Ok(Some(p)) => p,
Err(_) => { Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .into_response();
} }
}; };
// Validate exists let project_id = existing_project.id;
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();
}
// Validate request // Validate request
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
@@ -426,7 +408,7 @@ pub async fn update_project_handler(
/// Delete a project (requires authentication) /// Delete a project (requires authentication)
pub async fn delete_project_handler( pub async fn delete_project_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Check auth // Check auth
@@ -434,52 +416,37 @@ pub async fn delete_project_handler(
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
// Parse project ID // Fetch project before deletion to return it (lookup by UUID or slug)
let project_id = match uuid::Uuid::parse_str(&id) { let (project, tags, media) = match db::get_project_by_ref_with_tags(&state.pool, &ref_str).await
Ok(id) => id, {
Err(_) => { Ok(Some(data)) => data,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .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) // 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(()) => { 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 // Invalidate cached pages that display projects
state.isr_cache.invalidate("/").await; state.isr_cache.invalidate("/").await;
@@ -529,23 +496,35 @@ pub async fn get_admin_stats_handler(
/// Get tags for a project /// Get tags for a project
pub async fn get_project_tags_handler( pub async fn get_project_tags_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) { // Find project by ref (UUID or slug)
Ok(id) => id, let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Err(_) => { Ok(Some(p)) => p,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .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) => { Ok(tags) => {
let api_tags: Vec<db::ApiTag> = tags.into_iter().map(|t| t.to_api_tag()).collect(); let api_tags: Vec<db::ApiTag> = tags.into_iter().map(|t| t.to_api_tag()).collect();
Json(api_tags).into_response() Json(api_tags).into_response()
@@ -567,21 +546,34 @@ pub async fn get_project_tags_handler(
/// Add a tag to a project (requires authentication) /// Add a tag to a project (requires authentication)
pub async fn add_project_tag_handler( pub async fn add_project_tag_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
Json(payload): Json<AddProjectTagRequest>, Json(payload): Json<AddProjectTagRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() { if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id, // Find project by ref (UUID or slug)
Err(_) => { let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Ok(Some(p)) => p,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .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(()) => { Ok(()) => {
// Invalidate cached pages - tags affect how projects are displayed // Invalidate cached pages - tags affect how projects are displayed
state.isr_cache.invalidate("/").await; state.isr_cache.invalidate("/").await;
@@ -640,41 +632,66 @@ pub async fn add_project_tag_handler(
/// Remove a tag from a project (requires authentication) /// Remove a tag from a project (requires authentication)
pub async fn remove_project_tag_handler( pub async fn remove_project_tag_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
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, jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse { ) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() { if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response(); return auth::require_auth_response().into_response();
} }
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id, // Find project by ref (UUID or slug)
Err(_) => { let project = match db::get_project_by_ref(&state.pool, &ref_str).await {
Ok(Some(p)) => p,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid project ID", "error": "Not found",
"message": "Project ID must be a valid UUID" "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(); .into_response();
} }
}; };
let tag_id = match uuid::Uuid::parse_str(&tag_id) { // Find tag by ref (UUID or slug)
Ok(id) => id, let tag = match db::get_tag_by_ref(&state.pool, &tag_ref).await {
Err(_) => { Ok(Some(t)) => t,
Ok(None) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
"error": "Invalid tag ID", "error": "Not found",
"message": "Tag ID must be a valid UUID" "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(); .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(()) => { Ok(()) => {
// Invalidate cached pages - tags affect how projects are displayed // Invalidate cached pages - tags affect how projects are displayed
state.isr_cache.invalidate("/").await; state.isr_cache.invalidate("/").await;
+7 -7
View File
@@ -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( pub async fn get_tag_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> impl IntoResponse { ) -> 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(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await {
Ok(projects) => { Ok(projects) => {
let response = serde_json::json!({ let response = serde_json::json!({
@@ -157,7 +157,7 @@ pub async fn get_tag_handler(
/// Update a tag (requires authentication) /// Update a tag (requires authentication)
pub async fn update_tag_handler( pub async fn update_tag_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar, jar: axum_extra::extract::CookieJar,
Json(payload): Json<UpdateTagRequest>, Json(payload): Json<UpdateTagRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -189,7 +189,7 @@ pub async fn update_tag_handler(
.into_response(); .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(Some(tag)) => tag,
Ok(None) => { Ok(None) => {
return ( return (
@@ -255,9 +255,9 @@ pub async fn update_tag_handler(
/// 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>>,
axum::extract::Path(slug): axum::extract::Path<String>, axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> impl IntoResponse { ) -> 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(Some(tag)) => tag,
Ok(None) => { Ok(None) => {
return ( return (
+10 -8
View File
@@ -24,49 +24,51 @@ pub fn api_routes() -> Router<Arc<AppState>> {
.route("/session", get(handlers::api_session_handler)) .route("/session", get(handlers::api_session_handler))
// Projects - GET is public (shows all for admin, only non-hidden for public) // Projects - GET is public (shows all for admin, only non-hidden for public)
// POST/PUT/DELETE require authentication // POST/PUT/DELETE require authentication
// {ref} accepts either UUID or slug
.route( .route(
"/projects", "/projects",
get(handlers::projects_handler).post(handlers::create_project_handler), get(handlers::projects_handler).post(handlers::create_project_handler),
) )
.route( .route(
"/projects/{id}", "/projects/{ref}",
get(handlers::get_project_handler) get(handlers::get_project_handler)
.put(handlers::update_project_handler) .put(handlers::update_project_handler)
.delete(handlers::delete_project_handler), .delete(handlers::delete_project_handler),
) )
// Project tags - authentication checked in handlers // Project tags - authentication checked in handlers
.route( .route(
"/projects/{id}/tags", "/projects/{ref}/tags",
get(handlers::get_project_tags_handler).post(handlers::add_project_tag_handler), get(handlers::get_project_tags_handler).post(handlers::add_project_tag_handler),
) )
.route( .route(
"/projects/{id}/tags/{tag_id}", "/projects/{ref}/tags/{tag_ref}",
delete(handlers::remove_project_tag_handler), delete(handlers::remove_project_tag_handler),
) )
// Project media - GET is public, POST/PUT/DELETE require authentication // Project media - GET is public, POST/PUT/DELETE require authentication
.route( .route(
"/projects/{id}/media", "/projects/{ref}/media",
get(handlers::get_project_media_handler).post(handlers::upload_media_handler), get(handlers::get_project_media_handler).post(handlers::upload_media_handler),
) )
.route( .route(
"/projects/{id}/media/reorder", "/projects/{ref}/media/reorder",
put(handlers::reorder_media_handler), put(handlers::reorder_media_handler),
) )
.route( .route(
"/projects/{id}/media/{media_id}", "/projects/{ref}/media/{media_id}",
delete(handlers::delete_media_handler), delete(handlers::delete_media_handler),
) )
// Tags - authentication checked in handlers // Tags - authentication checked in handlers
// {ref} accepts either UUID or slug
.route( .route(
"/tags", "/tags",
get(handlers::list_tags_handler).post(handlers::create_tag_handler), get(handlers::list_tags_handler).post(handlers::create_tag_handler),
) )
.route( .route(
"/tags/{slug}", "/tags/{ref}",
get(handlers::get_tag_handler).put(handlers::update_tag_handler), get(handlers::get_tag_handler).put(handlers::update_tag_handler),
) )
.route( .route(
"/tags/{slug}/related", "/tags/{ref}/related",
get(handlers::get_related_tags_handler), get(handlers::get_related_tags_handler),
) )
.route( .route(