feat(companies): add /profile/submit-with-documents endpoint with local-disk fallback for B2 outages
All checks were successful
build-and-release / build (cron) (push) Successful in 14s
build-and-release / build (graphic-designers) (push) Successful in 3s
build-and-release / build (jobs) (push) Successful in 4s
build-and-release / build (job-seekers) (push) Successful in 6s
build-and-release / build (leads) (push) Successful in 4s
build-and-release / build (customers) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 3s
build-and-release / build (payments) (push) Successful in 4s
build-and-release / build (photographers) (push) Successful in 3s
build-and-release / build (catering-services) (push) Successful in 9s
build-and-release / build (tutors) (push) Successful in 5s
build-and-release / build (ugc-content-creators) (push) Successful in 4s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (users) (push) Successful in 4s
build-and-release / build (fitness-trainers) (push) Successful in 4s
build-and-release / build (video-editors) (push) Successful in 3s
build-and-release / build (developers) (push) Successful in 13s
build-and-release / build (gateway) (push) Successful in 4s
build-and-release / build (companies) (push) Successful in 6m38s
build-and-release / build (employees) (push) Successful in 13s
All checks were successful
build-and-release / build (cron) (push) Successful in 14s
build-and-release / build (graphic-designers) (push) Successful in 3s
build-and-release / build (jobs) (push) Successful in 4s
build-and-release / build (job-seekers) (push) Successful in 6s
build-and-release / build (leads) (push) Successful in 4s
build-and-release / build (customers) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 3s
build-and-release / build (payments) (push) Successful in 4s
build-and-release / build (photographers) (push) Successful in 3s
build-and-release / build (catering-services) (push) Successful in 9s
build-and-release / build (tutors) (push) Successful in 5s
build-and-release / build (ugc-content-creators) (push) Successful in 4s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (users) (push) Successful in 4s
build-and-release / build (fitness-trainers) (push) Successful in 4s
build-and-release / build (video-editors) (push) Successful in 3s
build-and-release / build (developers) (push) Successful in 13s
build-and-release / build (gateway) (push) Successful in 4s
build-and-release / build (companies) (push) Successful in 6m38s
build-and-release / build (employees) (push) Successful in 13s
This commit is contained in:
parent
5fa1f8f74c
commit
a019f86477
1 changed files with 399 additions and 0 deletions
|
|
@ -23,6 +23,7 @@ pub fn router() -> Router<AppState> {
|
|||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.route("/profile/documents", post(upload_documents))
|
||||
.route("/profile/submit", post(submit_for_verification))
|
||||
.route("/profile/submit-with-documents", post(submit_with_documents))
|
||||
.route("/jobs", get(list_jobs).post(create_job))
|
||||
.route("/jobs/{id}", get(get_job).patch(update_job))
|
||||
.route("/jobs/{id}/submit", post(submit_job))
|
||||
|
|
@ -746,3 +747,401 @@ async fn view_contact(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-disk fallback directory for company documents when B2 is down.
|
||||
const COMPANY_DOCS_LOCAL_DIR: &str = "/var/lib/nxtgauge-uploads/company_documents";
|
||||
|
||||
/// POST /api/companies/profile/submit-with-documents
|
||||
///
|
||||
/// Accepts multipart/form-data with:
|
||||
/// * `profile` — JSON string with company fields
|
||||
/// * `documents` — one or more file parts
|
||||
///
|
||||
/// In one request this handler:
|
||||
/// 1. Saves the profile into `company_profiles` (DRAFT upsert, same SQL as users service).
|
||||
/// 2. Uploads each document, trying B2 first then falling back to local disk
|
||||
/// (`/var/lib/nxtgauge-uploads/company_documents/{uuid}.{ext}`) and inserting
|
||||
/// a `company_documents` row regardless of source.
|
||||
/// 3. Creates a `verifications` row (case_type = "PROFILE_VERIFICATION", priority = "MEDIUM").
|
||||
/// 4. Updates `company_profiles.status` to 'PENDING'.
|
||||
/// 5. Returns `{ verification_id, status, documents_uploaded, documents_failed, storage_backend }`.
|
||||
async fn submit_with_documents(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
// ---- 1. Parse multipart: pull `profile` JSON first, then read each `documents` file
|
||||
// in-place (we can't hold a Field across loop iterations because Multipart is mutably
|
||||
// borrowed by next_field). We delay the company_documents DB insert until step 2b
|
||||
// because the company_profiles.id FK isn't available until the profile upsert runs. ----
|
||||
let mut profile_json_str: Option<String> = None;
|
||||
let mut uploaded_files: Vec<(String, i64, String)> = Vec::new(); // (url, data_len, content_type)
|
||||
let mut documents_meta: Vec<serde_json::Value> = Vec::new();
|
||||
let mut documents_uploaded: usize = 0;
|
||||
let mut documents_failed: usize = 0;
|
||||
let mut storage_backend: &str = "b2";
|
||||
let mut failure_details: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "profile" {
|
||||
match field.text().await {
|
||||
Ok(t) => profile_json_str = Some(t),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Failed to read profile field: {}", e) })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else if name == "documents" || name == "files" || name == "file" {
|
||||
// ---- 2a. Upload to storage (B2 with local-disk fallback). ----
|
||||
let original_filename = field.file_name().unwrap_or("").to_string();
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let ext = if !original_filename.is_empty() {
|
||||
original_filename
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("bin")
|
||||
.to_lowercase()
|
||||
} else {
|
||||
match content_type.as_str() {
|
||||
"application/pdf" => "pdf".to_string(),
|
||||
"image/jpeg" => "jpg".to_string(),
|
||||
"image/png" => "png".to_string(),
|
||||
_ => "bin".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
documents_failed += 1;
|
||||
failure_details.push(serde_json::json!({
|
||||
"filename": original_filename,
|
||||
"error": format!("Failed to read file bytes: {}", e),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if data.is_empty() {
|
||||
// silently skip empty files (matches existing upload_documents behaviour)
|
||||
continue;
|
||||
}
|
||||
|
||||
if data.len() > 10 * 1024 * 1024 {
|
||||
documents_failed += 1;
|
||||
failure_details.push(serde_json::json!({
|
||||
"filename": original_filename,
|
||||
"error": "File too large. Maximum 10 MB per file.",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let data_len = data.len() as i64;
|
||||
|
||||
// Try B2 first.
|
||||
let (url, source) = match state
|
||||
.storage
|
||||
.upload("company_documents", &ext, data.clone(), &content_type)
|
||||
.await
|
||||
{
|
||||
Ok(u) => (u, "b2"),
|
||||
Err(b2_err) => {
|
||||
tracing::warn!(
|
||||
"B2 upload failed for company user {} ({}) — falling back to local disk: {}",
|
||||
auth.user_id,
|
||||
original_filename,
|
||||
b2_err
|
||||
);
|
||||
// Fallback: write to local disk.
|
||||
let dir = std::path::Path::new(COMPANY_DOCS_LOCAL_DIR);
|
||||
if let Err(mkdir_err) = tokio::fs::create_dir_all(dir).await {
|
||||
tracing::error!(
|
||||
"Local-disk fallback unavailable: failed to create {}: {}",
|
||||
COMPANY_DOCS_LOCAL_DIR,
|
||||
mkdir_err
|
||||
);
|
||||
documents_failed += 1;
|
||||
failure_details.push(serde_json::json!({
|
||||
"filename": original_filename,
|
||||
"error": format!("B2 failed and local fallback mkdir failed: {}", mkdir_err),
|
||||
"b2_error": b2_err.to_string(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let file_id = Uuid::new_v4();
|
||||
let file_path = dir.join(format!("{}.{}", file_id, ext));
|
||||
if let Err(write_err) = tokio::fs::write(&file_path, &data).await {
|
||||
tracing::error!(
|
||||
"Local-disk fallback write failed for {}: {}",
|
||||
file_path.display(),
|
||||
write_err
|
||||
);
|
||||
documents_failed += 1;
|
||||
failure_details.push(serde_json::json!({
|
||||
"filename": original_filename,
|
||||
"error": format!("B2 failed and local fallback write failed: {}", write_err),
|
||||
"b2_error": b2_err.to_string(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
(format!("file://{}", file_path.display()), "local")
|
||||
}
|
||||
};
|
||||
|
||||
// Track storage backend for the response: once any file goes local, the request is "local".
|
||||
if source == "local" {
|
||||
storage_backend = "local";
|
||||
}
|
||||
|
||||
// Derive a stable document_type from the original filename (stem) so the admin UI can group.
|
||||
let document_type = if !original_filename.is_empty() {
|
||||
std::path::Path::new(&original_filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("document")
|
||||
.to_string()
|
||||
} else {
|
||||
"document".to_string()
|
||||
};
|
||||
|
||||
documents_meta.push(serde_json::json!({
|
||||
"url": url,
|
||||
"document_type": document_type,
|
||||
"file_size": data_len,
|
||||
"mime_type": content_type,
|
||||
}));
|
||||
uploaded_files.push((url, data_len, content_type));
|
||||
documents_uploaded += 1;
|
||||
}
|
||||
// ignore other fields silently
|
||||
}
|
||||
|
||||
let profile_str = match profile_json_str {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Missing 'profile' multipart field (JSON string)." })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let profile_obj: serde_json::Value = match serde_json::from_str(&profile_str) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Invalid profile JSON: {}", e) })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 2. Save profile to `company_profiles` (DRAFT upsert). ----
|
||||
// SQL mapping copied verbatim from apps/users/src/handlers/profile.rs:236-335 (COMPANY branch).
|
||||
let name = profile_obj
|
||||
.get("company_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let email = profile_obj
|
||||
.get("company_email")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let phone = profile_obj
|
||||
.get("company_phone")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let website = profile_obj
|
||||
.get("website")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let city = profile_obj
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let state_val = profile_obj
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let postal = profile_obj
|
||||
.get("pin_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let address = profile_obj
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let gst = profile_obj
|
||||
.get("gst_number")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO company_profiles (
|
||||
user_id, company_name, contact_email, contact_phone, website_url,
|
||||
address_line1, city, state, postal_code, gst_number, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'DRAFT', NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
company_name = EXCLUDED.company_name,
|
||||
contact_email = EXCLUDED.contact_email,
|
||||
contact_phone = EXCLUDED.contact_phone,
|
||||
website_url = EXCLUDED.website_url,
|
||||
address_line1 = EXCLUDED.address_line1,
|
||||
city = EXCLUDED.city,
|
||||
state = EXCLUDED.state,
|
||||
postal_code = EXCLUDED.postal_code,
|
||||
gst_number = EXCLUDED.gst_number,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.bind(&name)
|
||||
.bind(&email)
|
||||
.bind(&phone)
|
||||
.bind(&website)
|
||||
.bind(&address)
|
||||
.bind(&city)
|
||||
.bind(&state_val)
|
||||
.bind(&postal)
|
||||
.bind(&gst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
{
|
||||
tracing::error!("submit_with_documents: profile upsert failed for user {}: {}", auth.user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to save profile: {}", e) })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Look up the company_profiles.id for the company_documents FK.
|
||||
let company_id: Uuid = match sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT id FROM company_profiles WHERE user_id = $1",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!("submit_with_documents: cannot resolve company_id: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Profile saved but company record lookup failed" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 2b. Insert company_documents rows (deferred until we have company_id). ----
|
||||
// Same SQL as the existing upload_documents handler (apps/companies/src/handlers/mod.rs:612-624).
|
||||
// Errors are logged but not propagated — matches existing behaviour.
|
||||
for (url, data_len, content_type) in &uploaded_files {
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO company_documents (company_id, document_name, document_url, file_size, mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(format!("document_{}", Uuid::new_v4()))
|
||||
.bind(url)
|
||||
.bind(*data_len)
|
||||
.bind(content_type)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to save company_documents row for company {}: {}",
|
||||
company_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4. Create the verification record. ----
|
||||
// Signature: VerificationRepository::create(pool, user_id, role_key, case_type, priority, payload, documents)
|
||||
// (see crates/db/src/models/verification.rs:38). The DB column is `case_type`; the user service
|
||||
// populates it with "PROFILE_VERIFICATION" — we do the same.
|
||||
let verification = match VerificationRepository::create(
|
||||
&state.pool,
|
||||
auth.user_id,
|
||||
"COMPANY",
|
||||
"PROFILE_VERIFICATION",
|
||||
"MEDIUM",
|
||||
profile_obj.clone(),
|
||||
serde_json::Value::Array(documents_meta.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"submit_with_documents: failed to create verification for user {}: {}",
|
||||
auth.user_id,
|
||||
e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to create verification record: {}", e) })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 5. Mark the company_profiles row as PENDING. ----
|
||||
// Mirrors `set_profile_status` (COMPANY branch) in apps/users/src/handlers/profile.rs:606-614.
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE company_profiles SET status = 'PENDING', updated_at = NOW() WHERE user_id = $1",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"submit_with_documents: failed to update company_profiles.status for user {}: {}",
|
||||
auth.user_id,
|
||||
e
|
||||
);
|
||||
// Don't fail the whole request — the verification record is the source of truth for the admin queue.
|
||||
}
|
||||
|
||||
// Invalidate cached profile so the next read reflects the new status.
|
||||
let cache_key = format!("profile:company:{}", auth.user_id);
|
||||
let mut redis = state.redis.clone();
|
||||
let _ = redis.del::<_, ()>(&cache_key).await;
|
||||
|
||||
let mut response = serde_json::json!({
|
||||
"verification_id": verification.id,
|
||||
"status": "PENDING",
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_failed": documents_failed,
|
||||
"storage_backend": storage_backend,
|
||||
});
|
||||
if !failure_details.is_empty() {
|
||||
response["document_errors"] = serde_json::Value::Array(failure_details);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue