diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 54cf951..8e20d8a 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -83,7 +83,7 @@ impl Services { fn resolve_upstream(&self, path: &str) -> Option { - // 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") || path.starts_with("/api/users") || path.starts_with("/api/v1/users") @@ -96,6 +96,7 @@ impl Services { || path.starts_with("/api/kb") || path.starts_with("/api/packages") || path.starts_with("/api/support") + || path.starts_with("/api/reviews") || path.starts_with("/api/admin/roles") || path.starts_with("/api/admin/users") || path.starts_with("/api/admin/verifications") diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index 168bcca..8fa8f9a 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -380,6 +380,7 @@ async fn send_lead_request_ai( }; let expires_at = chrono::Utc::now() + chrono::Duration::hours(24); + let customer_id = lead.2.clone(); let result = sqlx::query_as::<_, LeadRequestRow>( r#" @@ -390,7 +391,7 @@ async fn send_lead_request_ai( ) .bind(payload.lead_id) .bind(user_role_profile_id) - .bind(user_id) + .bind(&customer_id) // customer_user_id from the lead .bind(tracecoins_cost) .bind(&ai_message) .bind(expires_at) @@ -419,7 +420,7 @@ async fn send_lead_request_ai( VALUES ($1, $2, $3, $4, $5) "# ) - .bind(user_id) + .bind(&customer_id) // notify the customer .bind("AI Auto-Respond Sent") .bind("Your AI-assisted response has been sent to the customer.") .bind("LEAD_REQUEST") diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 26e57fb..a4e54db 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::State, http::StatusCode, - routing::{get, post}, + routing::{get, post, patch}, Json, Router, }; use reqwest::Client; @@ -41,6 +41,14 @@ pub struct CreateLead { pub profession_key: String, } +#[derive(Debug, Deserialize)] +pub struct UpdateLead { + pub title: Option, + pub description: Option, + pub location: Option, + pub status: Option, +} + async fn list_leads(State(state): State>) -> Result>, StatusCode> { let leads = sqlx::query_as::<_, Lead>( "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" } +async fn update_lead( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, + Json(payload): Json, +) -> Result, 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] async fn main() { tracing_subscriber::registry() @@ -130,10 +170,13 @@ async fn main() { let app = Router::new() .route("/health", get(health)) - .route("/leads", get(list_leads)) - .route("/leads", post(create_lead)) - .route("/leads/{id}", get(get_lead)) - .nest("/api/lead-requests", lead_requests::router()) + .nest("/api", Router::new() + .route("/leads", get(list_leads)) + .route("/leads", post(create_lead)) + .route("/leads/{id}", get(get_lead)) + .route("/leads/{id}", patch(update_lead)) + .nest("/lead-requests", lead_requests::router()) + ) .layer(cors) .with_state(state); diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index f723cd3..7bc8da7 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -18,6 +18,7 @@ pub fn public_router() -> Router { .route("/categories", get(public_list_categories)) .route("/articles", get(public_list_articles)) .route("/articles/{slug}", get(public_get_article)) + .route("/articles/id/{id}", get(public_get_article_by_id)) } /// 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, + Path(id): Path, +) -> 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 ───────────────────────────────────────────────────────── async fn admin_list_categories( diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index d86142c..d6036b7 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -173,8 +173,8 @@ async fn submit( let query = format!( r#" - INSERT INTO {} (id, custom_data, status, updated_at) - VALUES ($1, $2, 'PENDING', NOW()) + INSERT INTO {} (id, user_id, custom_data, status, updated_at) + VALUES ($1, $2, $3, 'PENDING', NOW()) ON CONFLICT (id) DO UPDATE SET custom_data = EXCLUDED.custom_data, status = 'PENDING', @@ -185,6 +185,7 @@ async fn submit( sqlx::query(&query) .bind(user_role_profile_id) + .bind(auth.user_id) .bind(&progress) .execute(&state.pool) .await diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 8ed3d28..99ea117 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -7,7 +7,7 @@ use axum::{ Json, Router, }; 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 uuid::Uuid; @@ -19,8 +19,9 @@ pub fn router() -> Router { .route("/submit-for-verification", post(submit_for_verification)) } -pub fn me_verification_router() -> Router { +pub fn me_router() -> Router { Router::new() + .route("/", get(get_me)) .route("/verification-status", get(verification_status)) } @@ -557,3 +558,22 @@ async fn fetch_saved_profile_by_urp_id( } 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, +) -> Result { + 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, + }))) +} diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs index ab01f14..5ed75fb 100644 --- a/apps/users/src/handlers/reviews.rs +++ b/apps/users/src/handlers/reviews.rs @@ -1,9 +1,9 @@ use crate::AppState; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::get, + routing::{get, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; @@ -18,35 +18,50 @@ pub fn admin_router() -> Router { .route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review)) } +pub fn public_router() -> Router { + Router::new() + .route("/", get(list_reviews)) + .route("/professional/{professional_id}", get(list_reviews_by_professional)) +} + // ── DTOs ────────────────────────────────────────────────────────────────────── #[derive(Serialize)] struct ReviewDto { id: Uuid, - subject_type: String, - subject_id: Option, - reviewer_name: Option, - reviewer_id: Option, + professional_id: Uuid, + customer_id: Uuid, rating: i16, - title: Option, comment: Option, - status: String, + is_published: bool, created_at: chrono::DateTime, } +#[derive(Serialize)] +struct PublicReviewDto { + id: Uuid, + professional_id: Uuid, + rating: i16, + comment: Option, + created_at: String, +} + #[derive(Deserialize)] struct CreateReviewBody { - subject_type: Option, - subject_id: Option, - reviewer_name: Option, + lead_request_id: Uuid, rating: i16, - title: Option, comment: Option, } #[derive(Deserialize)] struct PatchReviewBody { - status: Option, + is_published: Option, +} + +#[derive(Deserialize)] +struct PublicListQuery { + page: Option, + limit: Option, } // ── FromRow structs ────────────────────────────────────────────────────────── @@ -54,14 +69,12 @@ struct PatchReviewBody { #[derive(sqlx::FromRow)] struct ReviewRow { id: Uuid, - subject_type: String, - subject_id: Option, - reviewer_name: Option, - reviewer_id: Option, + lead_request_id: Uuid, + customer_id: Uuid, + professional_id: Uuid, rating: i16, - title: Option, comment: Option, - status: String, + is_published: bool, created_at: chrono::DateTime, } @@ -75,14 +88,12 @@ async fn admin_list_reviews( r#" SELECT r.id, - r.subject_type, - r.subject_id, - r.reviewer_name, - r.reviewer_user_id AS reviewer_id, + r.lead_request_id, + r.customer_id, + r.professional_id, r.rating, - r.title, r.comment, - r.status, + r.is_published, r.created_at FROM reviews r ORDER BY r.created_at DESC @@ -97,14 +108,11 @@ async fn admin_list_reviews( .into_iter() .map(|r| ReviewDto { id: r.id, - subject_type: r.subject_type, - subject_id: r.subject_id, - reviewer_name: r.reviewer_name, - reviewer_id: r.reviewer_id, + professional_id: r.professional_id, + customer_id: r.customer_id, rating: r.rating, - title: r.title, comment: r.comment, - status: r.status, + is_published: r.is_published, created_at: r.created_at, }) .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(); } - let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string()); - let status = "PUBLISHED".to_string(); - let row = sqlx::query_as::<_, ReviewRow>( r#" - INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id, - rating, title, comment, status, created_at + INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published) + SELECT $1, + (SELECT id FROM customer_profiles WHERE user_id = (SELECT user_id FROM lead_requests WHERE id = $1)), + (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.subject_id) - .bind(&body.reviewer_name) + .bind(body.lead_request_id) .bind(body.rating) - .bind(&body.title) .bind(&body.comment) - .bind(&status) .fetch_one(&state.pool) .await; @@ -151,14 +154,11 @@ async fn admin_create_review( Ok(r) => { let dto = ReviewDto { id: r.id, - subject_type: r.subject_type, - subject_id: r.subject_id, - reviewer_name: r.reviewer_name, - reviewer_id: r.reviewer_id, + professional_id: r.professional_id, + customer_id: r.customer_id, rating: r.rating, - title: r.title, comment: r.comment, - status: r.status, + is_published: r.is_published, created_at: r.created_at, }; (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() @@ -176,13 +176,12 @@ async fn admin_update_review( Path(id): Path, Json(body): Json, ) -> 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( - "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(id) + .bind(is_published) .bind(id) .execute(&state.pool) .await; @@ -220,3 +219,107 @@ async fn admin_delete_review( } } } + +// ── Public handlers ──────────────────────────────────────────────────────────── + +async fn list_reviews( + State(state): State, + Query(q): Query, +) -> 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 = 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, + Path(professional_id): Path, + Query(q): Query, +) -> 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 = 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() + } + } +} diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index e0be1a4..24be59e 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -479,7 +479,7 @@ struct AdminTicketRow { created_at: chrono::DateTime, updated_at: chrono::DateTime, user_name: Option, - user_email: String, + user_email: Option, } 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 FROM support_tickets t 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) @@ -526,7 +530,7 @@ async fn admin_list_cases( .map(|r| { // Use user info if available, fall back to requester fields 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!({ "id": r.id, "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 FROM support_tickets t LEFT JOIN users u ON u.id = t.user_id - 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 + WHERE t.id = $1 "#, ) .bind(id) @@ -681,7 +681,7 @@ async fn admin_get_case( .collect(); 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!({ "ticket": { diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs index bd9254b..44a3037 100644 --- a/apps/users/src/handlers/user_roles.rs +++ b/apps/users/src/handlers/user_roles.rs @@ -8,6 +8,7 @@ use axum::{ }; use contracts::auth_middleware::AuthUser; use db::models::role::RoleRepository; +use db::models::user_role_profile::UserRoleProfileRepository; use serde::{Deserialize, Serialize}; pub fn router() -> Router { @@ -42,6 +43,7 @@ fn is_professional_role(role_key: &str) -> bool { | "SOCIAL_MEDIA_MANAGER" | "FITNESS_TRAINER" | "CATERING_SERVICES" + | "UGC_CONTENT_CREATOR" ) } @@ -112,7 +114,14 @@ async fn register_role( .await .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(( StatusCode::OK, diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 9479527..ce8dc43 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -72,8 +72,7 @@ async fn main() { .nest("/api/admin/approvals", handlers::approvals::router()) .nest("/api/admin/verifications", handlers::verifications::router()) // ── Me: Profile Status + Verification Status ────────────────────── - .nest("/api/me", handlers::onboarding::me_router()) - .nest("/api/me", handlers::profile::me_verification_router()) + .nest("/api/me", handlers::profile::me_router()) // ── Profile (save + submit-for-verification) ────────────────────── .nest("/api/profile", handlers::profile::router()) // ── Onboarding State (legacy, kept for compatibility) ──────────── @@ -97,7 +96,8 @@ async fn main() { .nest("/api/support/tickets", handlers::support::user_router()) // ── Support Tickets (admin) ─────────────────────────────────────── .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()) // ── Coupons & Discounts (admin) ─────────────────────────────────── .nest("/api/admin/coupons", handlers::coupons::coupons_router()) diff --git a/crates/db/migrations/20260610003321_create_leads.down.sql b/crates/db/migrations/20260610003321_create_leads.down.sql new file mode 100644 index 0000000..48ecbda --- /dev/null +++ b/crates/db/migrations/20260610003321_create_leads.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS leads; diff --git a/crates/db/migrations/20260610003321_create_leads.up.sql b/crates/db/migrations/20260610003321_create_leads.up.sql new file mode 100644 index 0000000..345c767 --- /dev/null +++ b/crates/db/migrations/20260610003321_create_leads.up.sql @@ -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); diff --git a/crates/db/migrations/20260610003322_create_lead_requests.down.sql b/crates/db/migrations/20260610003322_create_lead_requests.down.sql new file mode 100644 index 0000000..c1f4806 --- /dev/null +++ b/crates/db/migrations/20260610003322_create_lead_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lead_requests; diff --git a/crates/db/migrations/20260610003322_create_lead_requests.up.sql b/crates/db/migrations/20260610003322_create_lead_requests.up.sql new file mode 100644 index 0000000..bef33f9 --- /dev/null +++ b/crates/db/migrations/20260610003322_create_lead_requests.up.sql @@ -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); diff --git a/crates/db/migrations/20260610150000_fix_ugc_niche_tags.down.sql b/crates/db/migrations/20260610150000_fix_ugc_niche_tags.down.sql new file mode 100644 index 0000000..0a20a64 --- /dev/null +++ b/crates/db/migrations/20260610150000_fix_ugc_niche_tags.down.sql @@ -0,0 +1 @@ +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS niche_tags; diff --git a/crates/db/migrations/20260610150000_fix_ugc_niche_tags.up.sql b/crates/db/migrations/20260610150000_fix_ugc_niche_tags.up.sql new file mode 100644 index 0000000..b299620 --- /dev/null +++ b/crates/db/migrations/20260610150000_fix_ugc_niche_tags.up.sql @@ -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; diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs index c8b1d0c..d372ec9 100644 --- a/crates/db/src/models/lead_request.rs +++ b/crates/db/src/models/lead_request.rs @@ -6,20 +6,41 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct LeadRequest { pub id: Uuid, - pub user_role_profile_id: Uuid, + pub lead_id: Option, + pub user_role_profile_id: Option, + pub professional_user_id: Option, pub status: String, pub tracecoins_reserved: i32, + pub remarks: Option, pub expires_at: DateTime, pub requested_at: DateTime, pub resolved_at: Option>, - pub remarks: Option, + pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateLeadRequestPayload { + pub lead_id: Uuid, pub user_role_profile_id: Uuid, - pub expires_at: DateTime, + pub professional_user_id: Uuid, + pub remarks: Option, +} + +impl CreateLeadRequestPayload { + pub fn new( + lead_id: Uuid, + user_role_profile_id: Uuid, + professional_user_id: Uuid, + remarks: Option, + ) -> Self { + Self { + lead_id, + user_role_profile_id, + professional_user_id, + remarks, + } + } } pub struct LeadRequestRepository; @@ -31,13 +52,15 @@ impl LeadRequestRepository { ) -> Result { let req = sqlx::query_as::<_, LeadRequest>( r#" - INSERT INTO lead_requests (user_role_profile_id, expires_at) - VALUES ($1, $2) + INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, remarks) + VALUES ($1, $2, $3, $4) RETURNING * "#, ) + .bind(payload.lead_id) .bind(payload.user_role_profile_id) - .bind(payload.expires_at) + .bind(payload.professional_user_id) + .bind(&payload.remarks) .fetch_one(pool) .await?; @@ -92,6 +115,7 @@ impl LeadRequestRepository { .bind(id) .fetch_one(pool) .await?; + Ok(req) } }