mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 00:26:31 -06:00
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:
+88
@@ -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"
|
||||
}
|
||||
+46
@@ -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"
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user