From 623cda47b0a4e5d05ff8d9aa45faeb920ba99a22 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Jul 2026 11:39:54 +0200 Subject: [PATCH] fix: end-to-end workflow fixes for company/job-seeker and profession flows - companies/admin: add proper POST /jobs/{id}/approve and /jobs/{id}/reject endpoints (sets status=LIVE, not direct SQL bypass) - customers: fix list_requests to query by customer_user_id (not professional), add optional lead_id filter; fix debit to use professional_user_id - payments: switch razorpay_order_id column to payu_txnid (PayU migration) - users/auth: fix role resolution to not inject phantom roles for professionals - contracts/profession_shared: fix my_requests SQL to join leads+users instead of nonexistent requirements table - db/job_seeker: fix INSERT VALUES placeholder count (add missing $10) - storage: add MOCK_STORAGE=true mode for local dev without real B2 creds Co-Authored-By: Claude Sonnet 4.6 --- apps/companies/src/handlers/admin.rs | 55 ++++++++++++++++++++++- apps/customers/src/handlers.rs | 46 +++++++++++++------ apps/payments/src/main.rs | 2 +- apps/users/src/handlers/auth.rs | 4 +- crates/contracts/src/profession_shared.rs | 31 ++++++++----- crates/db/src/models/job_seeker.rs | 2 +- crates/db/src/models/lead_request.rs | 8 +++- crates/storage/src/lib.rs | 49 +++++++++++++------- 8 files changed, 147 insertions(+), 50 deletions(-) diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index d14acaf..9f0a516 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{get, patch}, + routing::{get, patch, post}, Json, Router, }; use chrono::{DateTime, Utc}; @@ -22,6 +22,8 @@ pub fn router() -> Router { .route("/{id}/reject", patch(reject_company)) .route("/{id}/suspend", patch(suspend_company)) .route("/jobs", get(list_jobs)) + .route("/jobs/{id}/approve", post(approve_job)) + .route("/jobs/{id}/reject", post(reject_job)) .route("/applications", get(list_applications)) } @@ -243,6 +245,57 @@ async fn list_jobs( Ok(Json(list)) } +#[derive(Deserialize)] +pub struct RejectJobPayload { + pub reason: Option, +} + +async fn approve_job( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let result = sqlx::query( + "UPDATE jobs SET status = 'LIVE', approved_at = NOW(), approved_by = $1 WHERE id = $2 AND status = 'PENDING_APPROVAL'" + ) + .bind(auth.user_id) + .bind(id) + .execute(&state.pool) + .await; + + match result { + Ok(r) if r.rows_affected() == 0 => { + (StatusCode::NOT_FOUND, "Job not found or not in PENDING_APPROVAL state").into_response() + } + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "status": "ACTIVE" }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn reject_job( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + let result = sqlx::query( + "UPDATE jobs SET status = 'REJECTED', rejection_reason = $1, approved_by = $2 WHERE id = $3 AND status = 'PENDING_APPROVAL'" + ) + .bind(payload.reason.as_deref().unwrap_or("")) + .bind(auth.user_id) + .bind(id) + .execute(&state.pool) + .await; + + match result { + Ok(r) if r.rows_affected() == 0 => { + (StatusCode::NOT_FOUND, "Job not found or not in PENDING_APPROVAL state").into_response() + } + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "status": "REJECTED" }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + async fn list_applications( _auth: AuthUser, State(state): State, diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 30d4c38..c02f7ac 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -32,6 +32,7 @@ pub fn router() -> Router { pub struct PaginationQuery { pub page: Option, pub limit: Option, + pub lead_id: Option, } #[derive(Deserialize)] @@ -240,19 +241,36 @@ async fn list_requests( let limit = q.limit.unwrap_or(20); let offset = (page - 1) * limit; - let rows_result = sqlx::query_as::<_, db::models::lead_request::LeadRequest>( - r#" - SELECT * FROM lead_requests - WHERE professional_user_id = $1 - ORDER BY requested_at DESC - LIMIT $2 OFFSET $3 - "# - ) - .bind(auth.user_id) - .bind(limit) - .bind(offset) - .fetch_all(&state.pool) - .await; + let rows_result = if let Some(lead_id) = q.lead_id { + sqlx::query_as::<_, db::models::lead_request::LeadRequest>( + r#" + SELECT * FROM lead_requests + WHERE customer_user_id = $1 AND lead_id = $2 + ORDER BY requested_at DESC + LIMIT $3 OFFSET $4 + "# + ) + .bind(auth.user_id) + .bind(lead_id) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + } else { + sqlx::query_as::<_, db::models::lead_request::LeadRequest>( + r#" + SELECT * FROM lead_requests + WHERE customer_user_id = $1 + ORDER BY requested_at DESC + LIMIT $2 OFFSET $3 + "# + ) + .bind(auth.user_id) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + }; match rows_result { Ok(leads) => (StatusCode::OK, Json(serde_json::json!({ @@ -301,7 +319,7 @@ async fn approve_request( Ok(updated) => { match TracecoinWalletRepository::try_debit_reserved_tracecoins( &state.pool, - lead.user_role_profile_id.unwrap(), + lead.professional_user_id.unwrap(), lead.tracecoins_reserved, lead.id, ).await { diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 7fc5901..68e541d 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -337,7 +337,7 @@ async fn create_order( sqlx::query( r#" - INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) + INSERT INTO payments (user_id, package_id, payu_txnid, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING') "#, ) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 1d0b711..4b7e852 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -156,9 +156,9 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str> } if normalized_intent.contains("PROFESSIONAL") { if let Some(p) = normalized_profession { - return vec![p, "PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; + return vec![p]; } - return vec!["PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; + return vec![]; } vec![] diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 2d3ceaf..53a61a1 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -200,6 +200,7 @@ async fn send_lead_request( req.id, user_role_profile.id, auth.user_id, + req.created_by_user_id, payload.message.clone(), ); @@ -394,15 +395,18 @@ async fn my_requests( let rows = if let Some(ref status) = q.status { sqlx::query_as::<_, RichLeadReq>( r#" - SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, + SELECT lr.*, + l.title as req_title, + l.profession_key as req_profession_key, + l.location as req_location, + l.budget_inr as req_budget, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr - LEFT JOIN requirements r ON r.id = lr.requirement_id - LEFT JOIN customers c ON c.id = r.customer_id - LEFT JOIN users u ON u.id = c.user_id - WHERE lr.user_role_profile_id = $1 AND lr.status = $2 + LEFT JOIN leads l ON l.id = lr.lead_id + LEFT JOIN users u ON u.id = lr.customer_user_id + WHERE lr.user_role_profile_id = $1 AND lr.status = $2 ORDER BY lr.requested_at DESC LIMIT $3 OFFSET $4 "# ) @@ -410,15 +414,18 @@ async fn my_requests( } else { sqlx::query_as::<_, RichLeadReq>( r#" - SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, + SELECT lr.*, + l.title as req_title, + l.profession_key as req_profession_key, + l.location as req_location, + l.budget_inr as req_budget, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr - LEFT JOIN requirements r ON r.id = lr.requirement_id - LEFT JOIN customers c ON c.id = r.customer_id - LEFT JOIN users u ON u.id = c.user_id - WHERE lr.user_role_profile_id = $1 + LEFT JOIN leads l ON l.id = lr.lead_id + LEFT JOIN users u ON u.id = lr.customer_user_id + WHERE lr.user_role_profile_id = $1 ORDER BY lr.requested_at DESC LIMIT $2 OFFSET $3 "# ) diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index 033dd1d..bc79844 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -90,7 +90,7 @@ impl JobSeekerRepository { user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, bio, custom_data ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (user_id) DO UPDATE SET first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs index d372ec9..3d5660b 100644 --- a/crates/db/src/models/lead_request.rs +++ b/crates/db/src/models/lead_request.rs @@ -24,6 +24,7 @@ pub struct CreateLeadRequestPayload { pub lead_id: Uuid, pub user_role_profile_id: Uuid, pub professional_user_id: Uuid, + pub customer_user_id: Option, pub remarks: Option, } @@ -32,12 +33,14 @@ impl CreateLeadRequestPayload { lead_id: Uuid, user_role_profile_id: Uuid, professional_user_id: Uuid, + customer_user_id: Option, remarks: Option, ) -> Self { Self { lead_id, user_role_profile_id, professional_user_id, + customer_user_id, remarks, } } @@ -52,14 +55,15 @@ impl LeadRequestRepository { ) -> Result { let req = sqlx::query_as::<_, LeadRequest>( r#" - INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, remarks) - VALUES ($1, $2, $3, $4) + INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, customer_user_id, remarks) + VALUES ($1, $2, $3, $4, $5) RETURNING * "#, ) .bind(payload.lead_id) .bind(payload.user_role_profile_id) .bind(payload.professional_user_id) + .bind(payload.customer_user_id) .bind(&payload.remarks) .fetch_one(pool) .await?; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 09b9630..bb2dfed 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -21,7 +21,7 @@ use uuid::Uuid; #[derive(Clone)] pub struct StorageClient { - client: Client, + client: Option, bucket: String, public_base_url: String, } @@ -34,15 +34,28 @@ impl StorageClient { } /// Build from environment variables. Panics if required vars are missing. + /// Set MOCK_STORAGE=true to skip real B2 uploads and return dummy URLs. pub async fn from_env() -> Self { - let key_id = Self::env_required("B2_ACCESS_KEY_ID", "B2_KEY_ID"); - let app_key = Self::env_required("B2_SECRET_ACCESS_KEY", "B2_APPLICATION_KEY"); - let bucket = std::env::var("B2_BUCKET_NAME").expect("B2_BUCKET_NAME must be set"); + let mock = std::env::var("MOCK_STORAGE") + .ok() + .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + + let bucket = std::env::var("B2_BUCKET_NAME").unwrap_or_else(|_| "mock-bucket".to_string()); let endpoint = std::env::var("B2_ENDPOINT") - .expect("B2_ENDPOINT must be set") + .unwrap_or_else(|_| "mock.storage.local".to_string()) .trim_start_matches("https://") .trim_start_matches("http://") .to_string(); + let public_base_url = format!("https://{}/{}", endpoint, bucket); + + if mock { + tracing::warn!("MOCK_STORAGE=true — file uploads will return dummy URLs"); + return Self { client: None, bucket, public_base_url }; + } + + let key_id = Self::env_required("B2_ACCESS_KEY_ID", "B2_KEY_ID"); + let app_key = Self::env_required("B2_SECRET_ACCESS_KEY", "B2_APPLICATION_KEY"); let region = std::env::var("B2_REGION").expect("B2_REGION must be set"); let use_path_style = std::env::var("B2_USE_PATH_STYLE") .ok() @@ -51,7 +64,6 @@ impl StorageClient { let creds = Credentials::new(key_id, app_key, None, None, "nxtgauge-storage"); let endpoint_url = format!("https://{}", endpoint); - let public_base_url = format!("https://{}/{}", endpoint, bucket); let s3_config = S3ConfigBuilder::new() .behavior_version(BehaviorVersion::latest()) @@ -62,7 +74,7 @@ impl StorageClient { .build(); let client = Client::from_conf(s3_config); - Self { client, bucket, public_base_url } + Self { client: Some(client), bucket, public_base_url } } /// Upload bytes to B2. Returns the public URL. @@ -72,24 +84,27 @@ impl StorageClient { pub async fn upload(&self, prefix: &str, ext: &str, data: Bytes, content_type: &str) -> Result { let key = format!("{}/{}.{}", prefix, Uuid::new_v4(), ext); - self.client - .put_object() - .bucket(&self.bucket) - .key(&key) - .body(ByteStream::from(data)) - .content_type(content_type) - .send() - .await - .context("B2 upload failed")?; + if let Some(client) = &self.client { + client + .put_object() + .bucket(&self.bucket) + .key(&key) + .body(ByteStream::from(data)) + .content_type(content_type) + .send() + .await + .context("B2 upload failed")?; + } Ok(format!("{}/{}", self.public_base_url, key)) } /// Delete a file by its full public URL (best-effort — logs on failure). pub async fn delete_by_url(&self, url: &str) { + let Some(client) = &self.client else { return }; let prefix = format!("{}/", self.public_base_url); if let Some(key) = url.strip_prefix(&prefix) { - if let Err(e) = self.client + if let Err(e) = client .delete_object() .bucket(&self.bucket) .key(key)