nxtgauge-backend-rust/apps/users/src/handlers/kb.rs
2026-04-18 18:30:56 +02:00

926 lines
28 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Routers ───────────────────────────────────────────────────────────────────
/// Public (unauthenticated) KB routes
pub fn public_router() -> Router<AppState> {
Router::new()
.route("/categories", get(public_list_categories))
.route("/articles", get(public_list_articles))
.route("/articles/{slug}", get(public_get_article))
}
/// Admin CRUD routes
pub fn admin_router() -> Router<AppState> {
Router::new()
// Categories
.route("/categories", get(admin_list_categories).post(admin_create_category))
.route(
"/categories/{id}",
patch(admin_update_category).delete(admin_delete_category),
)
// Articles
.route("/articles", get(admin_list_articles).post(admin_create_article))
.route(
"/articles/{id}",
get(admin_get_article)
.patch(admin_update_article)
.delete(admin_delete_article),
)
}
// ── Shared types ──────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct CategoryDto {
id: Uuid,
name: String,
slug: String,
description: Option<String>,
display_order: i32,
article_count: Option<i64>,
is_active: bool,
}
#[derive(Serialize)]
struct PublicArticleDto {
id: Uuid,
slug: String,
title: String,
summary: Option<String>,
#[serde(rename = "categoryKey")]
category_key: String,
category: String,
role: String,
tags: Vec<String>,
#[serde(rename = "updatedAt")]
updated_at: String,
content: String,
}
#[derive(Serialize)]
struct AdminArticleDto {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
category_id: Option<Uuid>,
category: Option<String>,
content: String,
status: String, // "PUBLISHED" | "DRAFT"
target_roles: Vec<String>,
tags: Vec<String>,
views: i32,
created_at: String,
updated_at: String,
}
// ── FromRow structs ───────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct CategoryRow {
id: Uuid,
name: String,
slug: String,
description: Option<String>,
display_order: i32,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct CategoryWithCountRow {
id: Uuid,
name: String,
slug: String,
description: Option<String>,
display_order: i32,
is_active: bool,
article_count: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct PublicArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
updated_at: chrono::DateTime<chrono::Utc>,
category_name: String,
category_slug: String,
}
#[derive(sqlx::FromRow)]
struct AdminArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
category_name: String,
}
#[derive(sqlx::FromRow)]
struct InsertedArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
// ── Public: categories ────────────────────────────────────────────────────────
async fn public_list_categories(State(state): State<AppState>) -> impl IntoResponse {
let rows = sqlx::query_as::<_, CategoryRow>(
r#"
SELECT id, name, slug, description, display_order, is_active
FROM kb_categories
WHERE is_active = true
ORDER BY display_order ASC, name ASC
"#,
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let data: Vec<_> = rows
.into_iter()
.map(|r| CategoryDto {
id: r.id,
name: r.name,
slug: r.slug,
description: r.description,
display_order: r.display_order,
article_count: None,
is_active: true,
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "categories": data }))).into_response()
}
Err(e) => {
tracing::error!("Failed to fetch KB categories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch categories" })),
)
.into_response()
}
}
}
// ── Public: articles list ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct PublicArticleQuery {
role: Option<String>,
category: Option<String>, // category slug
q: Option<String>,
page: Option<i64>,
limit: Option<i64>,
}
async fn public_list_articles(
State(state): State<AppState>,
Query(params): Query<PublicArticleQuery>,
) -> impl IntoResponse {
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(50).clamp(1, 100);
let offset = (page - 1) * limit;
let role_filter = params.role.as_deref().unwrap_or("").to_uppercase();
let cat_slug = params.category.as_deref().unwrap_or("").to_string();
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let rows = sqlx::query_as::<_, PublicArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.updated_at,
c.name AS category_name, c.slug AS category_slug
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.status = 'PUBLISHED'
AND c.is_active = true
AND ($1 = '' OR c.slug = $1)
AND ($2 = '' OR $2 = 'ALL'
OR a.target_roles = '{}'
OR $2 = ANY(a.target_roles))
AND ($3 = '' OR LOWER(a.title) LIKE '%' || $3 || '%'
OR LOWER(COALESCE(a.summary, '')) LIKE '%' || $3 || '%')
ORDER BY a.updated_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(&cat_slug)
.bind(&role_filter)
.bind(&q)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let articles: Vec<_> = rows
.into_iter()
.map(|r| {
let role = derive_role(r.target_roles.as_deref().unwrap_or(&[]));
PublicArticleDto {
id: r.id,
slug: r.slug,
title: r.title,
summary: r.summary,
category_key: r.category_slug,
category: r.category_name,
role,
tags: r.tags,
updated_at: r.updated_at.to_rfc3339(),
content: r.body,
}
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "articles": articles }))).into_response()
}
Err(e) => {
tracing::error!("Failed to fetch KB articles: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch articles" })),
)
.into_response()
}
}
}
// ── Public: single article by slug ────────────────────────────────────────────
async fn public_get_article(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, PublicArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.updated_at,
c.name AS category_name, c.slug AS category_slug
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.slug = $1 AND a.status = 'PUBLISHED' AND c.is_active = true
"#,
)
.bind(&slug)
.fetch_optional(&state.pool)
.await;
// Increment views in background (best-effort, not awaited)
let pool = state.pool.clone();
let slug_clone = slug.clone();
tokio::spawn(async move {
let _ = sqlx::query(
"UPDATE kb_articles SET views = views + 1 WHERE slug = $1",
)
.bind(slug_clone)
.execute(&pool)
.await;
});
match row {
Ok(Some(r)) => {
let role = derive_role(r.target_roles.as_deref().unwrap_or(&[]));
let dto = PublicArticleDto {
id: r.id,
slug: r.slug,
title: r.title,
summary: r.summary,
category_key: r.category_slug,
category: r.category_name,
role,
tags: r.tags,
updated_at: r.updated_at.to_rfc3339(),
content: r.body,
};
(StatusCode::OK, Json(dto)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Article not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch KB article {}: {}", slug, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch article" })),
)
.into_response()
}
}
}
// ── Admin: categories ─────────────────────────────────────────────────────────
async fn admin_list_categories(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query_as::<_, CategoryWithCountRow>(
r#"
SELECT
c.id, c.name, c.slug, c.description, c.display_order, c.is_active,
COUNT(a.id) AS article_count
FROM kb_categories c
LEFT JOIN kb_articles a ON a.category_id = c.id
GROUP BY c.id
ORDER BY c.display_order ASC, c.name ASC
"#,
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let data: Vec<_> = rows
.into_iter()
.map(|r| CategoryDto {
id: r.id,
name: r.name,
slug: r.slug,
description: r.description,
display_order: r.display_order,
article_count: r.article_count,
is_active: r.is_active,
})
.collect();
(StatusCode::OK, Json(data)).into_response()
}
Err(e) => {
tracing::error!("Failed to fetch KB categories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch categories" })),
)
.into_response()
}
}
}
#[derive(Deserialize)]
struct CreateCategoryBody {
name: String,
slug: String,
description: Option<String>,
display_order: Option<i32>,
}
async fn admin_create_category(
_auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateCategoryBody>,
) -> impl IntoResponse {
let order = body.display_order.unwrap_or(0);
let result = sqlx::query_as::<_, CategoryRow>(
r#"
INSERT INTO kb_categories (name, slug, description, display_order)
VALUES ($1, $2, $3, $4)
RETURNING id, name, slug, description, display_order, is_active
"#,
)
.bind(&body.name)
.bind(&body.slug)
.bind(&body.description)
.bind(order)
.fetch_one(&state.pool)
.await;
match result {
Ok(r) => (
StatusCode::CREATED,
Json(CategoryDto {
id: r.id,
name: r.name,
slug: r.slug,
description: r.description,
display_order: r.display_order,
article_count: Some(0),
is_active: r.is_active,
}),
)
.into_response(),
Err(e) if e.to_string().contains("unique") => (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "Slug already exists" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to create KB category: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to create category" })),
)
.into_response()
}
}
}
#[derive(Deserialize)]
struct UpdateCategoryBody {
name: Option<String>,
slug: Option<String>,
description: Option<String>,
display_order: Option<i32>,
is_active: Option<bool>,
}
async fn admin_update_category(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateCategoryBody>,
) -> impl IntoResponse {
let result = sqlx::query_as::<_, CategoryRow>(
r#"
UPDATE kb_categories SET
name = COALESCE($2, name),
slug = COALESCE($3, slug),
description = COALESCE($4, description),
display_order = COALESCE($5, display_order),
is_active = COALESCE($6, is_active)
WHERE id = $1
RETURNING id, name, slug, description, display_order, is_active
"#,
)
.bind(id)
.bind(&body.name)
.bind(&body.slug)
.bind(&body.description)
.bind(body.display_order)
.bind(body.is_active)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(r)) => (
StatusCode::OK,
Json(CategoryDto {
id: r.id,
name: r.name,
slug: r.slug,
description: r.description,
display_order: r.display_order,
article_count: None,
is_active: r.is_active,
}),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Category not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update KB category {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update category" })),
)
.into_response()
}
}
}
async fn admin_delete_category(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(
"DELETE FROM kb_categories WHERE id = $1 RETURNING id",
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(_)) => (
StatusCode::OK,
Json(serde_json::json!({ "deleted": true })),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Category not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete KB category {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to delete category" })),
)
.into_response()
}
}
}
// ── Admin: articles ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct AdminArticleQuery {
q: Option<String>,
category_id: Option<Uuid>,
status: Option<String>,
}
async fn admin_list_articles(
_auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<AdminArticleQuery>,
) -> impl IntoResponse {
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
let rows = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.status, a.views, a.category_id, a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE ($1 = '' OR LOWER(a.title) LIKE '%' || $1 || '%')
AND ($2::uuid IS NULL OR a.category_id = $2)
AND ($3::text IS NULL OR a.status = $3)
ORDER BY a.updated_at DESC
LIMIT 200
"#,
)
.bind(&q)
.bind(params.category_id)
.bind(status_filter)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let articles: Vec<_> = rows
.into_iter()
.map(|r| AdminArticleDto {
id: r.id,
title: r.title,
slug: r.slug,
summary: r.summary,
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "articles": articles }))).into_response()
}
Err(e) => {
tracing::error!("Failed to fetch admin KB articles: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch articles" })),
)
.into_response()
}
}
}
#[derive(Deserialize)]
struct CreateArticleBody {
title: String,
slug: Option<String>,
summary: Option<String>,
content: String, // maps to body
category_id: Option<Uuid>,
status: Option<String>, // "PUBLISHED" | "DRAFT"
target_roles: Option<Vec<String>>,
tags: Option<Vec<String>>,
}
async fn admin_create_article(
auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateArticleBody>,
) -> impl IntoResponse {
let slug = body
.slug
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slugify(&body.title));
let status = body.status.as_deref().unwrap_or("DRAFT").to_string();
let roles: Vec<String> = body.target_roles.unwrap_or_default();
let tags: Vec<String> = body.tags.unwrap_or_default();
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
INSERT INTO kb_articles
(title, slug, summary, body, category_id, status, target_roles, tags, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, title, slug, summary, body, category_id, status,
target_roles, tags, views, created_at, updated_at
"#,
)
.bind(&body.title)
.bind(&slug)
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(&status)
.bind(&roles)
.bind(&tags)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await;
match result {
Ok(r) => (
StatusCode::CREATED,
Json(AdminArticleDto {
id: r.id,
title: r.title,
slug: r.slug,
summary: r.summary,
category_id: Some(r.category_id),
category: None,
content: r.body,
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}),
)
.into_response(),
Err(e) if e.to_string().contains("unique") => (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "Slug already exists" })),
)
.into_response(),
Err(e) if e.to_string().contains("not-null") || e.to_string().contains("null value") => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "category_id is required" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to create KB article: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to create article" })),
)
.into_response()
}
}
}
async fn admin_get_article(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.category_id,
a.target_roles, a.tags, a.status, a.views,
a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.id = $1
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match row {
Ok(Some(r)) => (
StatusCode::OK,
Json(AdminArticleDto {
id: r.id,
title: r.title,
slug: r.slug,
summary: r.summary,
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Article not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch KB article {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch article" })),
)
.into_response()
}
}
}
#[derive(Deserialize)]
struct UpdateArticleBody {
title: Option<String>,
slug: Option<String>,
summary: Option<String>,
content: Option<String>,
category_id: Option<Uuid>,
status: Option<String>,
target_roles: Option<Vec<String>>,
tags: Option<Vec<String>>,
}
async fn admin_update_article(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateArticleBody>,
) -> impl IntoResponse {
let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
UPDATE kb_articles SET
title = COALESCE($2, title),
slug = COALESCE($3, slug),
summary = COALESCE($4, summary),
body = COALESCE($5, body),
category_id = COALESCE($6, category_id),
status = COALESCE($7, status),
target_roles = COALESCE($8, target_roles),
tags = COALESCE($9, tags),
updated_at = NOW()
WHERE id = $1
RETURNING id, title, slug, summary, body, category_id,
target_roles, tags, status, views, created_at, updated_at
"#,
)
.bind(id)
.bind(&body.title)
.bind(&body.slug)
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(&status)
.bind(body.target_roles.as_deref())
.bind(body.tags.as_deref())
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(r)) => (
StatusCode::OK,
Json(AdminArticleDto {
id: r.id,
title: r.title,
slug: r.slug,
summary: r.summary,
category_id: Some(r.category_id),
category: None,
content: r.body,
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Article not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update KB article {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update article" })),
)
.into_response()
}
}
}
async fn admin_delete_article(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(
"DELETE FROM kb_articles WHERE id = $1 RETURNING id",
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(_)) => (
StatusCode::OK,
Json(serde_json::json!({ "deleted": true })),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Article not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete KB article {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to delete article" })),
)
.into_response()
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Map target_roles array to a single frontend role label
fn derive_role(roles: &[String]) -> String {
if roles.is_empty() {
return "ALL".to_string();
}
let profession_keys = [
"PHOTOGRAPHER", "MAKEUP_ARTIST", "TUTOR", "DEVELOPER",
"VIDEO_EDITOR", "GRAPHIC_DESIGNER", "SOCIAL_MEDIA_MANAGER",
"FITNESS_TRAINER", "CATERING_SERVICES",
];
let first = roles[0].as_str();
if first == "COMPANY" { return "company".to_string(); }
if first == "JOB_SEEKER" { return "jobSeeker".to_string(); }
if first == "CUSTOMER" { return "customer".to_string(); }
if profession_keys.contains(&first) { return "professional".to_string(); }
"ALL".to_string()
}
/// Simple ASCII slug from title
fn slugify(title: &str) -> String {
title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}