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)
#[allow(clippy::too_many_arguments)]
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
}
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> {
let rows = sqlx::query!(
r#"
+58 -86
View File
@@ -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<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,
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<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> 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<db::ApiProjectMedia> =
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<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,
) -> 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<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,
Json(payload): Json<ReorderMediaRequest>,
) -> 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;
+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(
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,
) -> 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<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateProjectRequest>,
) -> 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<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
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<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> 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<db::ApiTag> = 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<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
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();
}
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<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,
) -> 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;
+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(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> 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<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<UpdateTagRequest>,
) -> 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<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
axum::extract::Path(ref_str): axum::extract::Path<String>,
) -> 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 (
+10 -8
View File
@@ -24,49 +24,51 @@ pub fn api_routes() -> Router<Arc<AppState>> {
.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(