fix(e2e): 14 bug fixes across users, leads, gateway, KB, and reviews

DB:
- Add niche_tags column to ugc_content_creator_profiles (was blocking UGC service)
- Add turnaround_days and fix user_role_profile_id NOT NULL for UGC
- leads/lead_requests tables (already created in session 1)

Code:
- Add UGC_CONTENT_CREATOR to is_professional_role() to auto-create user_role_profiles
- Fix onboarding INSERT to include user_id for photographer_profiles
- Fix send_lead_request_ai to use correct customer_user_id (was self-notifying)
- Add PATCH /api/leads/:id support + mount leads at /api/* for gateway compatibility
- Fix admin_list_cases query (WHERE was using wrong params)
- Fix admin_get_case query (was using list query instead of fetch-by-id)
- Add GET /api/me in profile.rs (moved from onboarding)
- Add KB articles by ID route /api/kb/articles/id/{id}
- Rewrite reviews handlers to match actual reviews table schema
- Add public reviews router GET /api/reviews

Gateway:
- Add /api/reviews route to users service
This commit is contained in:
Tracewebstudio Dev 2026-06-10 16:17:10 +02:00
parent 52e30a1b4b
commit 2c6d102205
17 changed files with 407 additions and 84 deletions

View file

@ -83,7 +83,7 @@ impl Services {
fn resolve_upstream(&self, path: &str) -> Option<String> { fn resolve_upstream(&self, path: &str) -> Option<String> {
// Auth, users, roles, notifications, runtime-config, config, KB, support // Auth, users, roles, notifications, runtime-config, config, KB, support, reviews
if path.starts_with("/api/auth") if path.starts_with("/api/auth")
|| path.starts_with("/api/users") || path.starts_with("/api/users")
|| path.starts_with("/api/v1/users") || path.starts_with("/api/v1/users")
@ -96,6 +96,7 @@ impl Services {
|| path.starts_with("/api/kb") || path.starts_with("/api/kb")
|| path.starts_with("/api/packages") || path.starts_with("/api/packages")
|| path.starts_with("/api/support") || path.starts_with("/api/support")
|| path.starts_with("/api/reviews")
|| path.starts_with("/api/admin/roles") || path.starts_with("/api/admin/roles")
|| path.starts_with("/api/admin/users") || path.starts_with("/api/admin/users")
|| path.starts_with("/api/admin/verifications") || path.starts_with("/api/admin/verifications")

View file

@ -380,6 +380,7 @@ async fn send_lead_request_ai(
}; };
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24); let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
let customer_id = lead.2.clone();
let result = sqlx::query_as::<_, LeadRequestRow>( let result = sqlx::query_as::<_, LeadRequestRow>(
r#" r#"
@ -390,7 +391,7 @@ async fn send_lead_request_ai(
) )
.bind(payload.lead_id) .bind(payload.lead_id)
.bind(user_role_profile_id) .bind(user_role_profile_id)
.bind(user_id) .bind(&customer_id) // customer_user_id from the lead
.bind(tracecoins_cost) .bind(tracecoins_cost)
.bind(&ai_message) .bind(&ai_message)
.bind(expires_at) .bind(expires_at)
@ -419,7 +420,7 @@ async fn send_lead_request_ai(
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
"# "#
) )
.bind(user_id) .bind(&customer_id) // notify the customer
.bind("AI Auto-Respond Sent") .bind("AI Auto-Respond Sent")
.bind("Your AI-assisted response has been sent to the customer.") .bind("Your AI-assisted response has been sent to the customer.")
.bind("LEAD_REQUEST") .bind("LEAD_REQUEST")

View file

@ -1,7 +1,7 @@
use axum::{ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post, patch},
Json, Router, Json, Router,
}; };
use reqwest::Client; use reqwest::Client;
@ -41,6 +41,14 @@ pub struct CreateLead {
pub profession_key: String, pub profession_key: String,
} }
#[derive(Debug, Deserialize)]
pub struct UpdateLead {
pub title: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub status: Option<String>,
}
async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> { async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> {
let leads = sqlx::query_as::<_, Lead>( let leads = sqlx::query_as::<_, Lead>(
"SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC" "SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC"
@ -94,6 +102,38 @@ async fn health() -> &'static str {
"Leads Service OK" "Leads Service OK"
} }
async fn update_lead(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
Json(payload): Json<UpdateLead>,
) -> Result<Json<Lead>, StatusCode> {
let status = payload.status.as_deref().unwrap_or("OPEN");
let lead = sqlx::query_as::<_, Lead>(
r#"
UPDATE leads
SET title = COALESCE($1, title),
description = COALESCE($2, description),
location = COALESCE($3, location),
status = $4,
updated_at = NOW()
WHERE id = $5
RETURNING id, title, description, location, profession_key, status, created_at
"#,
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.location)
.bind(status)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(lead))
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::registry() tracing_subscriber::registry()
@ -130,10 +170,13 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/leads", get(list_leads)) .nest("/api", Router::new()
.route("/leads", post(create_lead)) .route("/leads", get(list_leads))
.route("/leads/{id}", get(get_lead)) .route("/leads", post(create_lead))
.nest("/api/lead-requests", lead_requests::router()) .route("/leads/{id}", get(get_lead))
.route("/leads/{id}", patch(update_lead))
.nest("/lead-requests", lead_requests::router())
)
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);

View file

@ -18,6 +18,7 @@ pub fn public_router() -> Router<AppState> {
.route("/categories", get(public_list_categories)) .route("/categories", get(public_list_categories))
.route("/articles", get(public_list_articles)) .route("/articles", get(public_list_articles))
.route("/articles/{slug}", get(public_get_article)) .route("/articles/{slug}", get(public_get_article))
.route("/articles/id/{id}", get(public_get_article_by_id))
} }
/// Admin CRUD routes /// Admin CRUD routes
@ -346,6 +347,68 @@ async fn public_get_article(
} }
} }
// ── Public: single article by ID ───────────────────────────────────────────────
async fn public_get_article_by_id(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> 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.id = $1 AND a.status = 'PUBLISHED' AND c.is_active = true
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
let pool = state.pool.clone();
tokio::spawn(async move {
let _ = sqlx::query("UPDATE kb_articles SET views = views + 1 WHERE id = $1")
.bind(id)
.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 by id {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch article" })),
)
.into_response()
}
}
}
// ── Admin: categories ───────────────────────────────────────────────────────── // ── Admin: categories ─────────────────────────────────────────────────────────
async fn admin_list_categories( async fn admin_list_categories(

View file

@ -173,8 +173,8 @@ async fn submit(
let query = format!( let query = format!(
r#" r#"
INSERT INTO {} (id, custom_data, status, updated_at) INSERT INTO {} (id, user_id, custom_data, status, updated_at)
VALUES ($1, $2, 'PENDING', NOW()) VALUES ($1, $2, $3, 'PENDING', NOW())
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
custom_data = EXCLUDED.custom_data, custom_data = EXCLUDED.custom_data,
status = 'PENDING', status = 'PENDING',
@ -185,6 +185,7 @@ async fn submit(
sqlx::query(&query) sqlx::query(&query)
.bind(user_role_profile_id) .bind(user_role_profile_id)
.bind(auth.user_id)
.bind(&progress) .bind(&progress)
.execute(&state.pool) .execute(&state.pool)
.await .await

View file

@ -7,7 +7,7 @@ use axum::{
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use db::models::{role::RoleRepository, verification::VerificationRepository}; use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -19,8 +19,9 @@ pub fn router() -> Router<AppState> {
.route("/submit-for-verification", post(submit_for_verification)) .route("/submit-for-verification", post(submit_for_verification))
} }
pub fn me_verification_router() -> Router<AppState> { pub fn me_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(get_me))
.route("/verification-status", get(verification_status)) .route("/verification-status", get(verification_status))
} }
@ -557,3 +558,22 @@ async fn fetch_saved_profile_by_urp_id(
} }
serde_json::Value::Object(Default::default()) serde_json::Value::Object(Default::default())
} }
/// GET /api/me — returns the authenticated user's basic info
pub async fn get_me(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": user.id,
"email": user.email,
"firstName": user.first_name,
"lastName": user.last_name,
"activeRole": auth.claims.active_role,
"emailVerified": user.email_verified,
})))
}

View file

@ -1,9 +1,9 @@
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get, routing::{get, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
@ -18,35 +18,50 @@ pub fn admin_router() -> Router<AppState> {
.route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review)) .route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review))
} }
pub fn public_router() -> Router<AppState> {
Router::new()
.route("/", get(list_reviews))
.route("/professional/{professional_id}", get(list_reviews_by_professional))
}
// ── DTOs ────────────────────────────────────────────────────────────────────── // ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct ReviewDto { struct ReviewDto {
id: Uuid, id: Uuid,
subject_type: String, professional_id: Uuid,
subject_id: Option<String>, customer_id: Uuid,
reviewer_name: Option<String>,
reviewer_id: Option<Uuid>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
status: String, is_published: bool,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Serialize)]
struct PublicReviewDto {
id: Uuid,
professional_id: Uuid,
rating: i16,
comment: Option<String>,
created_at: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateReviewBody { struct CreateReviewBody {
subject_type: Option<String>, lead_request_id: Uuid,
subject_id: Option<String>,
reviewer_name: Option<String>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct PatchReviewBody { struct PatchReviewBody {
status: Option<String>, is_published: Option<bool>,
}
#[derive(Deserialize)]
struct PublicListQuery {
page: Option<i64>,
limit: Option<i64>,
} }
// ── FromRow structs ────────────────────────────────────────────────────────── // ── FromRow structs ──────────────────────────────────────────────────────────
@ -54,14 +69,12 @@ struct PatchReviewBody {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ReviewRow { struct ReviewRow {
id: Uuid, id: Uuid,
subject_type: String, lead_request_id: Uuid,
subject_id: Option<String>, customer_id: Uuid,
reviewer_name: Option<String>, professional_id: Uuid,
reviewer_id: Option<Uuid>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
status: String, is_published: bool,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
@ -75,14 +88,12 @@ async fn admin_list_reviews(
r#" r#"
SELECT SELECT
r.id, r.id,
r.subject_type, r.lead_request_id,
r.subject_id, r.customer_id,
r.reviewer_name, r.professional_id,
r.reviewer_user_id AS reviewer_id,
r.rating, r.rating,
r.title,
r.comment, r.comment,
r.status, r.is_published,
r.created_at r.created_at
FROM reviews r FROM reviews r
ORDER BY r.created_at DESC ORDER BY r.created_at DESC
@ -97,14 +108,11 @@ async fn admin_list_reviews(
.into_iter() .into_iter()
.map(|r| ReviewDto { .map(|r| ReviewDto {
id: r.id, id: r.id,
subject_type: r.subject_type, professional_id: r.professional_id,
subject_id: r.subject_id, customer_id: r.customer_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
rating: r.rating, rating: r.rating,
title: r.title,
comment: r.comment, comment: r.comment,
status: r.status, is_published: r.is_published,
created_at: r.created_at, created_at: r.created_at,
}) })
.collect(); .collect();
@ -126,24 +134,19 @@ async fn admin_create_review(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response(); return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
} }
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
let status = "PUBLISHED".to_string();
let row = sqlx::query_as::<_, ReviewRow>( let row = sqlx::query_as::<_, ReviewRow>(
r#" r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status) INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published)
VALUES ($1, $2, $3, $4, $5, $6, $7) SELECT $1,
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id, (SELECT id FROM customer_profiles WHERE user_id = (SELECT user_id FROM lead_requests WHERE id = $1)),
rating, title, comment, status, created_at (SELECT user_role_profile_id FROM lead_requests WHERE id = $1),
$2, $3, true
RETURNING id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
"#, "#,
) )
.bind(&subject_type) .bind(body.lead_request_id)
.bind(&body.subject_id)
.bind(&body.reviewer_name)
.bind(body.rating) .bind(body.rating)
.bind(&body.title)
.bind(&body.comment) .bind(&body.comment)
.bind(&status)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
@ -151,14 +154,11 @@ async fn admin_create_review(
Ok(r) => { Ok(r) => {
let dto = ReviewDto { let dto = ReviewDto {
id: r.id, id: r.id,
subject_type: r.subject_type, professional_id: r.professional_id,
subject_id: r.subject_id, customer_id: r.customer_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
rating: r.rating, rating: r.rating,
title: r.title,
comment: r.comment, comment: r.comment,
status: r.status, is_published: r.is_published,
created_at: r.created_at, created_at: r.created_at,
}; };
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
@ -176,13 +176,12 @@ async fn admin_update_review(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>, Json(body): Json<PatchReviewBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string(); let is_published = body.is_published.unwrap_or(true);
let result = sqlx::query( let result = sqlx::query(
"UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2", "UPDATE reviews SET is_published = $1, updated_at = NOW() WHERE id = $2",
) )
.bind(&status) .bind(is_published)
.bind(id)
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await; .await;
@ -220,3 +219,107 @@ async fn admin_delete_review(
} }
} }
} }
// ── Public handlers ────────────────────────────────────────────────────────────
async fn list_reviews(
State(state): State<AppState>,
Query(q): Query<PublicListQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>(
r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
FROM reviews
WHERE is_published = true
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list public reviews: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
}
}
}
async fn list_reviews_by_professional(
State(state): State<AppState>,
Path(professional_id): Path<Uuid>,
Query(q): Query<PublicListQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>(
r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
FROM reviews
WHERE professional_id = $1 AND is_published = true
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(professional_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let avg: (f64,) = sqlx::query_as("SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE professional_id = $1 AND is_published = true")
.bind(professional_id)
.fetch_one(&state.pool)
.await
.unwrap_or((0.0,));
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
.bind(professional_id)
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({
"reviews": dtos,
"averageRating": avg.0,
"totalCount": count.0
}))).into_response()
}
Err(e) => {
tracing::error!("Failed to list reviews for professional {professional_id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
}
}
}

View file

@ -479,7 +479,7 @@ struct AdminTicketRow {
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
user_name: Option<String>, user_name: Option<String>,
user_email: String, user_email: Option<String>,
} }
async fn admin_list_cases( async fn admin_list_cases(
@ -503,7 +503,11 @@ async fn admin_list_cases(
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = $1 WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#, "#,
) )
.bind(&status_filter) .bind(&status_filter)
@ -526,7 +530,7 @@ async fn admin_list_cases(
.map(|r| { .map(|r| {
// Use user info if available, fall back to requester fields // Use user info if available, fall back to requester fields
let requester_name = r.requester_name.or(r.user_name); let requester_name = r.requester_name.or(r.user_name);
let requester_email = r.requester_email.or(Some(r.user_email)); let requester_email = r.requester_email.or(r.user_email);
serde_json::json!({ serde_json::json!({
"id": r.id, "id": r.id,
"title": r.subject, "title": r.subject,
@ -642,11 +646,7 @@ async fn admin_get_case(
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1) WHERE t.id = $1
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#, "#,
) )
.bind(id) .bind(id)
@ -681,7 +681,7 @@ async fn admin_get_case(
.collect(); .collect();
let requester_name = t.requester_name.or(t.user_name); let requester_name = t.requester_name.or(t.user_name);
let requester_email = t.requester_email.or(Some(t.user_email)); let requester_email = t.requester_email.or(t.user_email);
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"ticket": { "ticket": {

View file

@ -8,6 +8,7 @@ use axum::{
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use db::models::role::RoleRepository; use db::models::role::RoleRepository;
use db::models::user_role_profile::UserRoleProfileRepository;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -42,6 +43,7 @@ fn is_professional_role(role_key: &str) -> bool {
| "SOCIAL_MEDIA_MANAGER" | "SOCIAL_MEDIA_MANAGER"
| "FITNESS_TRAINER" | "FITNESS_TRAINER"
| "CATERING_SERVICES" | "CATERING_SERVICES"
| "UGC_CONTENT_CREATOR"
) )
} }
@ -112,7 +114,14 @@ async fn register_role(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Note: Professional profile creation is now handled upon successful submission of onboarding data. // For professional/external roles, also create the user_role_profiles entry so
// downstream services (e.g. leads) can find the professional's profile.
if is_professional_role(&role_key) {
if let Err(e) = UserRoleProfileRepository::create(&state.pool, auth.user_id, &role_key).await {
tracing::warn!("Failed to create user_role_profiles entry for {}: {}", role_key, e);
// Non-fatal — the assignment is created; the profile row can be backfilled later.
}
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,

View file

@ -72,8 +72,7 @@ async fn main() {
.nest("/api/admin/approvals", handlers::approvals::router()) .nest("/api/admin/approvals", handlers::approvals::router())
.nest("/api/admin/verifications", handlers::verifications::router()) .nest("/api/admin/verifications", handlers::verifications::router())
// ── Me: Profile Status + Verification Status ────────────────────── // ── Me: Profile Status + Verification Status ──────────────────────
.nest("/api/me", handlers::onboarding::me_router()) .nest("/api/me", handlers::profile::me_router())
.nest("/api/me", handlers::profile::me_verification_router())
// ── Profile (save + submit-for-verification) ────────────────────── // ── Profile (save + submit-for-verification) ──────────────────────
.nest("/api/profile", handlers::profile::router()) .nest("/api/profile", handlers::profile::router())
// ── Onboarding State (legacy, kept for compatibility) ──────────── // ── Onboarding State (legacy, kept for compatibility) ────────────
@ -97,7 +96,8 @@ async fn main() {
.nest("/api/support/tickets", handlers::support::user_router()) .nest("/api/support/tickets", handlers::support::user_router())
// ── Support Tickets (admin) ─────────────────────────────────────── // ── Support Tickets (admin) ───────────────────────────────────────
.nest("/api/admin/support-cases", handlers::support::admin_router()) .nest("/api/admin/support-cases", handlers::support::admin_router())
// ── Reviews (admin) ─────────────────────────────────────────────── // ── Reviews (public + admin) ─────────────────────────────────────
.nest("/api/reviews", handlers::reviews::public_router())
.nest("/api/admin/reviews", handlers::reviews::admin_router()) .nest("/api/admin/reviews", handlers::reviews::admin_router())
// ── Coupons & Discounts (admin) ─────────────────────────────────── // ── Coupons & Discounts (admin) ───────────────────────────────────
.nest("/api/admin/coupons", handlers::coupons::coupons_router()) .nest("/api/admin/coupons", handlers::coupons::coupons_router())

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS leads;

View file

@ -0,0 +1,27 @@
-- Create the leads table (also called "requirements" in some contexts)
-- Required by RequirementRepository in crates/db/src/models/requirement.rs
CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profession_key VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
location VARCHAR(255) NOT NULL DEFAULT '',
budget_inr INT,
required_date DATE,
extra_data_json JSONB,
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
rejection_reason TEXT,
request_count INT NOT NULL DEFAULT 0,
accepted_count INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
approved_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_user_id UUID,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
CREATE INDEX IF NOT EXISTS idx_leads_profession_key ON leads(profession_key);
CREATE INDEX IF NOT EXISTS idx_leads_created_by_user_id ON leads(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_leads_status_profession ON leads(status, profession_key);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS lead_requests;

View file

@ -0,0 +1,21 @@
-- Create the lead_requests table for professional responses to leads
CREATE TABLE IF NOT EXISTS lead_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
user_role_profile_id UUID NOT NULL,
customer_user_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
tracecoins_reserved INT NOT NULL DEFAULT 0,
message TEXT,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ,
rejected_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id);
CREATE INDEX IF NOT EXISTS idx_lead_requests_user_role_profile_id ON lead_requests(user_role_profile_id);
CREATE INDEX IF NOT EXISTS idx_lead_requests_customer_user_id ON lead_requests(customer_user_id);
CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status);

View file

@ -0,0 +1 @@
ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS niche_tags;

View file

@ -0,0 +1,7 @@
-- Fix UGC Content Creator schema: rename content_niches→niche_tags (matches Rust model)
ALTER TABLE ugc_content_creator_profiles
ADD COLUMN IF NOT EXISTS niche_tags TEXT[] DEFAULT '{}';
UPDATE ugc_content_creator_profiles
SET niche_tags = COALESCE(content_niches, '{}')
WHERE niche_tags IS NULL;

View file

@ -6,20 +6,41 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct LeadRequest { pub struct LeadRequest {
pub id: Uuid, pub id: Uuid,
pub user_role_profile_id: Uuid, pub lead_id: Option<Uuid>,
pub user_role_profile_id: Option<Uuid>,
pub professional_user_id: Option<Uuid>,
pub status: String, pub status: String,
pub tracecoins_reserved: i32, pub tracecoins_reserved: i32,
pub remarks: Option<String>,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
pub requested_at: DateTime<Utc>, pub requested_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>, pub resolved_at: Option<DateTime<Utc>>,
pub remarks: Option<String>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CreateLeadRequestPayload { pub struct CreateLeadRequestPayload {
pub lead_id: Uuid,
pub user_role_profile_id: Uuid, pub user_role_profile_id: Uuid,
pub expires_at: DateTime<Utc>, pub professional_user_id: Uuid,
pub remarks: Option<String>,
}
impl CreateLeadRequestPayload {
pub fn new(
lead_id: Uuid,
user_role_profile_id: Uuid,
professional_user_id: Uuid,
remarks: Option<String>,
) -> Self {
Self {
lead_id,
user_role_profile_id,
professional_user_id,
remarks,
}
}
} }
pub struct LeadRequestRepository; pub struct LeadRequestRepository;
@ -31,13 +52,15 @@ impl LeadRequestRepository {
) -> Result<LeadRequest, sqlx::Error> { ) -> Result<LeadRequest, sqlx::Error> {
let req = sqlx::query_as::<_, LeadRequest>( let req = sqlx::query_as::<_, LeadRequest>(
r#" r#"
INSERT INTO lead_requests (user_role_profile_id, expires_at) INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, remarks)
VALUES ($1, $2) VALUES ($1, $2, $3, $4)
RETURNING * RETURNING *
"#, "#,
) )
.bind(payload.lead_id)
.bind(payload.user_role_profile_id) .bind(payload.user_role_profile_id)
.bind(payload.expires_at) .bind(payload.professional_user_id)
.bind(&payload.remarks)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -92,6 +115,7 @@ impl LeadRequestRepository {
.bind(id) .bind(id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(req) Ok(req)
} }
} }