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
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:
parent
3546b2caf0
commit
623cda47b0
8 changed files with 147 additions and 50 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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![]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,24 +84,27 @@ 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
|
||||
.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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue