fix: customer document submission and profile verification
customer_profiles already had custom_data but never got a `status` column, so — same root cause already fixed for job_seeker_profiles — the generic profile save/get/submit handlers failed outright for the CUSTOMER role, and the admin final-approval path couldn't write a verified status either. - Migration: add customer_profiles.status (default 'DRAFT'). - Special-case CUSTOMER in the generic profile.rs handlers (get_profile, save_profile, fetch_saved_profile, set_profile_status), mirroring the existing JOB_SEEKER special-case, storing basic-tab fields under custom_data.basic_info. - Re-enable the CUSTOMER branch in activate_profile_after_final_approval now that the status column exists. - Add the missing POST /api/customers/profile/documents upload endpoint — the frontend's document upload (required: Aadhar/Government ID) targets this exact path for CUSTOMER and previously 404'd since no such route was ever registered. Mirrors the B2-upload-only pattern used by the profession apps' shared upload_document handler. Verified separately: requirement posting (POST /api/customers/requirements) and requirement submission-for-verification (POST /api/customers/requirements/:id/submit) already work correctly — both use the `leads` table (an active migration, despite the model's "Requirement" naming) and properly create a verification record (case_type REQUIREMENT_APPROVAL) that lands in admin Verification Management via the existing approve_requirement/reject_requirement handlers. No changes needed there.
This commit is contained in:
parent
4e292efbdf
commit
b5dea58ed4
8 changed files with 109 additions and 28 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1143,6 +1143,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"auth",
|
"auth",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"contracts",
|
"contracts",
|
||||||
"db",
|
"db",
|
||||||
|
|
@ -1150,6 +1151,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"storage",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { workspace = true }
|
axum = { workspace = true, features = ["multipart"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
|
@ -12,9 +12,11 @@ tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
db = { path = "../../crates/db" }
|
db = { path = "../../crates/db" }
|
||||||
auth = { path = "../../crates/auth" }
|
auth = { path = "../../crates/auth" }
|
||||||
contracts = { path = "../../crates/contracts" }
|
contracts = { path = "../../crates/contracts" }
|
||||||
|
storage = { path = "../../crates/storage" }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
email = { path = "../../crates/email" }
|
email = { path = "../../crates/email" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use bytes::BufMut;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
|
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
|
||||||
|
|
@ -20,6 +21,7 @@ pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||||
.route("/profile/submit", post(submit_for_verification))
|
.route("/profile/submit", post(submit_for_verification))
|
||||||
|
.route("/profile/documents", post(upload_document))
|
||||||
.route("/requirements", get(list_requirements).post(create_requirement))
|
.route("/requirements", get(list_requirements).post(create_requirement))
|
||||||
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
|
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
|
||||||
.route("/requirements/{id}/submit", post(submit_requirement))
|
.route("/requirements/{id}/submit", post(submit_requirement))
|
||||||
|
|
@ -74,6 +76,71 @@ async fn update_profile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/customers/profile/documents — uploads a KYC document (e.g. Aadhar)
|
||||||
|
/// to B2 storage and returns its URL. The caller merges the URL into
|
||||||
|
/// profile_data and persists it via PATCH /api/profile, same as every other
|
||||||
|
/// role's document flow (see extract_documents in apps/users/src/handlers/profile.rs).
|
||||||
|
async fn upload_document(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth: AuthUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut file_bytes = bytes::BytesMut::new();
|
||||||
|
let mut content_type = "application/octet-stream".to_string();
|
||||||
|
let mut ext = "bin".to_string();
|
||||||
|
let mut found = false;
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
if name == "document" || name == "file" || !found {
|
||||||
|
if let Some(ct) = field.content_type() {
|
||||||
|
content_type = ct.to_string();
|
||||||
|
ext = match ct {
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"application/pdf" => "pdf",
|
||||||
|
_ => "bin",
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
} else if let Some(fname) = field.file_name() {
|
||||||
|
if let Some(e) = fname.rsplit('.').next() {
|
||||||
|
ext = e.to_lowercase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = match field.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Empty file" }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.len() > 10 * 1024 * 1024 {
|
||||||
|
return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB." }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
file_bytes.put(data);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || file_bytes.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No document file provided. Send a multipart field named 'document'." }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.storage.upload("documents", &ext, file_bytes.freeze(), &content_type).await {
|
||||||
|
Ok(url) => (StatusCode::OK, Json(serde_json::json!({ "url": url }))).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("B2 upload failed: {:?}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_for_verification(
|
async fn submit_for_verification(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use sqlx::PgPool;
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
pub mail: Arc<email::Mailer>,
|
pub mail: Arc<email::Mailer>,
|
||||||
|
pub storage: Arc<storage::StorageClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -34,7 +35,8 @@ async fn main() {
|
||||||
tracing::info!("Customers service — connected to database");
|
tracing::info!("Customers service — connected to database");
|
||||||
|
|
||||||
let mailer = Arc::new(email::Mailer::new());
|
let mailer = Arc::new(email::Mailer::new());
|
||||||
let state = AppState { pool, mail: mailer };
|
let storage = Arc::new(storage::StorageClient::from_env().await);
|
||||||
|
let state = AppState { pool, mail: mailer, storage };
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api/customers", handlers::router())
|
.nest("/api/customers", handlers::router())
|
||||||
|
|
|
||||||
|
|
@ -189,12 +189,7 @@ async fn activate_profile_after_final_approval(
|
||||||
// by its own id with a separate user_role_profile_id FK column —
|
// by its own id with a separate user_role_profile_id FK column —
|
||||||
// matching those against `id` (as this previously did) never hit any
|
// matching those against `id` (as this previously did) never hit any
|
||||||
// row, silently no-opping the approval.
|
// row, silently no-opping the approval.
|
||||||
// NOTE: customer_profiles has the same user_id-keyed shape but is left
|
if role_key == "COMPANY" || role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
|
||||||
// out here — its status column doesn't exist in the DB yet (blocked on
|
|
||||||
// the still-disabled requirements/leads migration), a separate,
|
|
||||||
// pre-existing issue in the customer/requirements vertical, out of scope
|
|
||||||
// for this fix.
|
|
||||||
if role_key == "COMPANY" || role_key == "JOB_SEEKER" {
|
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1",
|
"UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1",
|
||||||
table
|
table
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,10 @@ async fn get_profile(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if role_key == "JOB_SEEKER" {
|
if role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
|
||||||
return match sqlx::query(
|
let table = if role_key == "JOB_SEEKER" { "job_seeker_profiles" } else { "customer_profiles" };
|
||||||
r#"SELECT custom_data, status FROM job_seeker_profiles WHERE user_id = $1"#,
|
let query = format!(r#"SELECT custom_data, status FROM {} WHERE user_id = $1"#, table);
|
||||||
)
|
return match sqlx::query(&query)
|
||||||
.bind(auth.user_id)
|
.bind(auth.user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -360,13 +360,15 @@ async fn save_profile(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if role_key == "JOB_SEEKER" {
|
if role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
|
||||||
// job_seeker_profiles also stores the job_seeker_portfolio blob (written
|
let table = if role_key == "JOB_SEEKER" { "job_seeker_profiles" } else { "customer_profiles" };
|
||||||
// by the dedicated /api/jobseeker/profile/me endpoint) inside custom_data,
|
|
||||||
// so basic-tab fields are merged in under a nested "basic_info" key
|
// Both tables also store other blobs inside custom_data (job_seeker_portfolio
|
||||||
// instead of overwriting custom_data wholesale.
|
// for JOB_SEEKER, written by the dedicated /api/jobseeker/profile/me endpoint),
|
||||||
|
// so basic-tab fields are merged in under a nested "basic_info" key instead of
|
||||||
|
// overwriting custom_data wholesale.
|
||||||
let existing_custom_data: serde_json::Value = sqlx::query_scalar(
|
let existing_custom_data: serde_json::Value = sqlx::query_scalar(
|
||||||
r#"SELECT custom_data FROM job_seeker_profiles WHERE user_id = $1"#,
|
&format!(r#"SELECT custom_data FROM {} WHERE user_id = $1"#, table),
|
||||||
)
|
)
|
||||||
.bind(auth.user_id)
|
.bind(auth.user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
|
|
@ -382,15 +384,16 @@ async fn save_profile(
|
||||||
merged.insert("basic_info".to_string(), input.profile_data.clone());
|
merged.insert("basic_info".to_string(), input.profile_data.clone());
|
||||||
let merged_custom_data = serde_json::Value::Object(merged);
|
let merged_custom_data = serde_json::Value::Object(merged);
|
||||||
|
|
||||||
return match sqlx::query(
|
let query = format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO job_seeker_profiles (user_id, custom_data, status, updated_at)
|
INSERT INTO {table} (user_id, custom_data, status, updated_at)
|
||||||
VALUES ($1, $2, 'DRAFT', NOW())
|
VALUES ($1, $2, 'DRAFT', NOW())
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
custom_data = EXCLUDED.custom_data,
|
custom_data = EXCLUDED.custom_data,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
"#,
|
"#
|
||||||
)
|
);
|
||||||
|
return match sqlx::query(&query)
|
||||||
.bind(auth.user_id)
|
.bind(auth.user_id)
|
||||||
.bind(&merged_custom_data)
|
.bind(&merged_custom_data)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -402,7 +405,7 @@ async fn save_profile(
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("save_profile(JOB_SEEKER) failed for user {}: {}", auth.user_id, e);
|
tracing::error!("save_profile({}) failed for user {}: {}", role_key, auth.user_id, e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -679,9 +682,10 @@ async fn fetch_saved_profile(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if role_key == "JOB_SEEKER" {
|
if role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
|
||||||
|
let table = if role_key == "JOB_SEEKER" { "job_seeker_profiles" } else { "customer_profiles" };
|
||||||
let custom_data: serde_json::Value = sqlx::query_scalar(
|
let custom_data: serde_json::Value = sqlx::query_scalar(
|
||||||
r#"SELECT custom_data FROM job_seeker_profiles WHERE user_id = $1"#,
|
&format!(r#"SELECT custom_data FROM {} WHERE user_id = $1"#, table),
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
|
|
@ -716,9 +720,10 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if role_key == "JOB_SEEKER" {
|
if role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
|
||||||
|
let table = if role_key == "JOB_SEEKER" { "job_seeker_profiles" } else { "customer_profiles" };
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"UPDATE job_seeker_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#,
|
&format!(r#"UPDATE {} SET status = $1, updated_at = NOW() WHERE user_id = $2"#, table),
|
||||||
)
|
)
|
||||||
.bind(status)
|
.bind(status)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE customer_profiles DROP COLUMN IF EXISTS status;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- customer_profiles has always had custom_data (used for generic profile
|
||||||
|
-- save via apps/users/src/handlers/profile.rs) but never got a `status`
|
||||||
|
-- column, so verification-status tracking for the CUSTOMER role fails
|
||||||
|
-- outright with "column does not exist" — same root cause already fixed
|
||||||
|
-- for job_seeker_profiles.
|
||||||
|
ALTER TABLE customer_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'DRAFT';
|
||||||
Loading…
Add table
Reference in a new issue