fix: end-to-end workflow fixes for company/job-seeker and profession flows
All checks were successful
build-and-release / build (employees) (push) Successful in 1m25s
build-and-release / build (developers) (push) Successful in 1m41s
build-and-release / build (customers) (push) Successful in 1m43s
build-and-release / build (cron) (push) Successful in 1m57s
build-and-release / build (catering-services) (push) Successful in 2m19s
build-and-release / build (companies) (push) Successful in 2m44s
build-and-release / build (fitness-trainers) (push) Successful in 1m36s
build-and-release / build (gateway) (push) Successful in 1m40s
build-and-release / build (graphic-designers) (push) Successful in 1m50s
build-and-release / build (jobs) (push) Successful in 1m42s
build-and-release / build (job-seekers) (push) Successful in 2m31s
build-and-release / build (leads) (push) Successful in 2m1s
build-and-release / build (photographers) (push) Successful in 1m46s
build-and-release / build (makeup-artists) (push) Successful in 2m30s
build-and-release / build (social-media-managers) (push) Successful in 2m42s
build-and-release / build (tutors) (push) Successful in 2m16s
build-and-release / build (video-editors) (push) Successful in 2m19s
build-and-release / build (payments) (push) Successful in 4m40s
build-and-release / build (ugc-content-creators) (push) Successful in 4m35s
build-and-release / build (users) (push) Successful in 4m45s

- 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 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-07-18 11:39:54 +02:00
parent 3546b2caf0
commit 623cda47b0
8 changed files with 147 additions and 50 deletions

View file

@ -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<AppState> {
.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<String>,
}
async fn approve_job(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RejectJobPayload>,
) -> 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<AppState>,

View file

@ -32,6 +32,7 @@ pub fn router() -> Router<AppState> {
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub lead_id: Option<Uuid>,
}
#[derive(Deserialize)]
@ -240,10 +241,26 @@ 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>(
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 professional_user_id = $1
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
"#
@ -252,7 +269,8 @@ async fn list_requests(
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
.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 {

View file

@ -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')
"#,
)

View file

@ -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![]

View file

@ -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,14 +395,17 @@ 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
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,14 +414,17 @@ 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
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
"#

View file

@ -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,

View file

@ -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<Uuid>,
pub remarks: Option<String>,
}
@ -32,12 +33,14 @@ impl CreateLeadRequestPayload {
lead_id: Uuid,
user_role_profile_id: Uuid,
professional_user_id: Uuid,
customer_user_id: Option<Uuid>,
remarks: Option<String>,
) -> Self {
Self {
lead_id,
user_role_profile_id,
professional_user_id,
customer_user_id,
remarks,
}
}
@ -52,14 +55,15 @@ impl LeadRequestRepository {
) -> Result<LeadRequest, sqlx::Error> {
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?;

View file

@ -21,7 +21,7 @@ use uuid::Uuid;
#[derive(Clone)]
pub struct StorageClient {
client: Client,
client: Option<Client>,
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,7 +84,8 @@ impl StorageClient {
pub async fn upload(&self, prefix: &str, ext: &str, data: Bytes, content_type: &str) -> Result<String> {
let key = format!("{}/{}.{}", prefix, Uuid::new_v4(), ext);
self.client
if let Some(client) = &self.client {
client
.put_object()
.bucket(&self.bucket)
.key(&key)
@ -81,15 +94,17 @@ impl StorageClient {
.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)