mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -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)
|
/// 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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user