diff --git a/apps/users/src/handlers/coupons.rs b/apps/users/src/handlers/coupons.rs index 87b9609..378d90c 100644 --- a/apps/users/src/handlers/coupons.rs +++ b/apps/users/src/handlers/coupons.rs @@ -432,6 +432,12 @@ async fn update_discount( .execute(&state.pool) .await; + match result { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "updated": true }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), + } +} + #[derive(Deserialize)] struct ValidateCouponPayload { coupon_code: String, @@ -553,7 +559,7 @@ async fn validate_coupon( .await .unwrap_or(Some(0)) .unwrap_or(0); - if count >= max_uses { + if count >= max_uses as i64 { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { @@ -587,6 +593,4 @@ async fn validate_coupon( message: "Coupon applied".to_string(), }), )) -} - } } diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index c9153e7..a3bae90 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -18,3 +18,4 @@ pub mod support; pub mod user_roles; pub mod external_roles; pub mod verifications; +pub mod profile; diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs new file mode 100644 index 0000000..9ad4346 --- /dev/null +++ b/apps/users/src/handlers/profile.rs @@ -0,0 +1,479 @@ +use crate::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, patch, post}, + Json, Router, +}; +use contracts::auth_middleware::AuthUser; +use db::models::{role::RoleRepository, verification::VerificationRepository}; +use serde::Deserialize; +use uuid::Uuid; + +// ── Routers ─────────────────────────────────────────────────────────────────── + +pub fn router() -> Router { + Router::new() + .route("/", get(get_profile).patch(save_profile)) + .route("/submit-for-verification", post(submit_for_verification)) +} + +pub fn me_verification_router() -> Router { + Router::new() + .route("/verification-status", get(verification_status)) +} + +// ── DTOs ────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct RoleKeyQuery { + #[serde(rename = "roleKey", alias = "role_key")] + pub role_key: Option, +} + +#[derive(Deserialize)] +pub struct SaveProfileInput { + #[serde(rename = "roleKey", alias = "role_key")] + pub role_key: String, + pub profile_data: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct SubmitInput { + #[serde(rename = "roleKey", alias = "role_key")] + pub role_key: String, + /// Optional: if provided, saves this data before submitting. + /// If omitted, reads previously saved profile data from DB. + pub profile_data: Option, +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn role_to_table(role_key: &str) -> Option<&'static str> { + match role_key.to_uppercase().as_str() { + "PHOTOGRAPHER" => Some("photographer_profiles"), + "MAKEUP_ARTIST" => Some("makeup_artist_profiles"), + "TUTOR" => Some("tutor_profiles"), + "DEVELOPER" => Some("developer_profiles"), + "VIDEO_EDITOR" => Some("video_editor_profiles"), + "GRAPHIC_DESIGNER" => Some("graphic_designer_profiles"), + "SOCIAL_MEDIA_MANAGER" => Some("social_media_manager_profiles"), + "FITNESS_TRAINER" => Some("fitness_trainer_profiles"), + "CATERING_SERVICES" => Some("catering_service_profiles"), + "UGC_CONTENT_CREATOR" => Some("ugc_content_creator_profiles"), + "JOB_SEEKER" | "JOBSEEKER" => Some("job_seeker_profiles"), + "CUSTOMER" => Some("customer_profiles"), + _ => None, + } +} + +fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value { + let doc_keys = [ + "aadhar_doc", + "registration_doc", + "gst_doc", + "sample_work", + "degree_certificate", + "certification_doc", + "fssai_license", + "identity_proof", + "address_proof", + "portfolio_proof", + ]; + let mut docs = vec![]; + for key in &doc_keys { + if let Some(val) = profile_data.get(key) { + if !val.is_null() { + docs.push(serde_json::json!({ + "type": key, + "value": val, + "status": "SUBMITTED" + })); + } + } + } + serde_json::Value::Array(docs) +} + +fn resolve_role_key(auth_role: &str, query_role: Option) -> String { + query_role + .filter(|k| !k.is_empty()) + .unwrap_or_else(|| auth_role.to_string()) + .to_uppercase() +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/// GET /api/profile?roleKey=PHOTOGRAPHER +async fn get_profile( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let role_key = resolve_role_key(&auth.claims.active_role, q.role_key); + + if role_key == "COMPANY" { + let row = sqlx::query( + r#"SELECT name, status, "updatedAt" FROM companies WHERE "userId" = $1"#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await; + + return match row { + Ok(Some(r)) => { + use sqlx::Row; + let name: Option = r.try_get("name").ok(); + let status: String = r.try_get("status").unwrap_or_default(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": { "company_name": name }, + "verification_status": status, + })), + ) + .into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": null, + "verification_status": "NOT_STARTED", + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + } + + let table = match role_to_table(&role_key) { + Some(t) => t, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Unknown role: {}", role_key) })), + ) + .into_response() + } + }; + + let query = format!( + r#"SELECT "profileData", verification_status FROM {} WHERE user_id = $1"#, + table + ); + + match sqlx::query(&query) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(row)) => { + use sqlx::Row; + let profile_data: serde_json::Value = row + .try_get("profileData") + .unwrap_or(serde_json::Value::Null); + let verification_status: String = + row.try_get("verification_status").unwrap_or_default(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": profile_data, + "verification_status": verification_status, + })), + ) + .into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": null, + "verification_status": "NOT_STARTED", + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// PATCH /api/profile +async fn save_profile( + auth: AuthUser, + State(state): State, + Json(input): Json, +) -> impl IntoResponse { + let role_key = input.role_key.to_uppercase(); + + if role_key == "COMPANY" { + let name = input + .profile_data + .get("company_name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + return match sqlx::query( + r#" + INSERT INTO companies ("userId", name, status, "updatedAt") + VALUES ($1, $2, 'DRAFT', NOW()) + ON CONFLICT ("userId") DO UPDATE SET + name = EXCLUDED.name, + "updatedAt" = NOW() + "#, + ) + .bind(auth.user_id) + .bind(&name) + .execute(&state.pool) + .await + { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "saved": true, "role_key": role_key })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + } + + let table = match role_to_table(&role_key) { + Some(t) => t, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Unknown role: {}", role_key) })), + ) + .into_response() + } + }; + + let query = format!( + r#" + INSERT INTO {table} (user_id, "profileData", verification_status, updated_at) + VALUES ($1, $2, 'DRAFT', NOW()) + ON CONFLICT (user_id) DO UPDATE SET + "profileData" = EXCLUDED."profileData", + updated_at = NOW() + "# + ); + + match sqlx::query(&query) + .bind(auth.user_id) + .bind(&input.profile_data) + .execute(&state.pool) + .await + { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "saved": true, "role_key": role_key })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// POST /api/profile/submit-for-verification +async fn submit_for_verification( + auth: AuthUser, + State(state): State, + Json(input): Json, +) -> impl IntoResponse { + let role_key = input.role_key.to_uppercase(); + + // Guard: reject if an active verification already exists + let existing: Result, sqlx::Error> = sqlx::query_scalar( + r#" + SELECT id FROM verifications + WHERE user_id = $1 AND role_key = $2 + AND status IN ('PENDING', 'UNDER_REVIEW', 'DOCUMENTS_REQUESTED', 'REVISION_REQUESTED') + LIMIT 1 + "#, + ) + .bind(auth.user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await; + + if existing.unwrap_or(None).is_some() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "A verification is already in progress for this role. Please wait for it to be reviewed." + })), + ) + .into_response(); + } + + // Fetch saved profile data or use submitted data + let profile_data = match input.profile_data { + Some(data) => data, + None => fetch_saved_profile(&state, auth.user_id, &role_key).await, + }; + + let documents = extract_documents(&profile_data); + + // Mark profile as PENDING in role-specific table + set_profile_status(&state, auth.user_id, &role_key, "PENDING").await; + + // Mark user_role as PENDING + if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { + sqlx::query( + "UPDATE user_roles SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2", + ) + .bind(auth.user_id) + .bind(role.id) + .execute(&state.pool) + .await + .ok(); + } + + // Create verification record — appears in admin Verification Management + match VerificationRepository::create( + &state.pool, + auth.user_id, + &role_key, + "PROFILE_VERIFICATION", + "MEDIUM", + profile_data, + documents, + ) + .await + { + Ok(v) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "verification_id": v.id, + "status": v.status, + "message": "Your profile has been submitted for verification. We will notify you once it has been reviewed." + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER +pub async fn verification_status( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let role_key = resolve_role_key(&auth.claims.active_role, q.role_key); + + let row = sqlx::query( + r#" + SELECT id, status, notes, rejection_reason, updated_at + FROM verifications + WHERE user_id = $1 AND role_key = $2 + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(auth.user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await; + + match row { + Ok(Some(r)) => { + use sqlx::Row; + let id: Uuid = r.try_get("id").unwrap_or(Uuid::nil()); + let status: String = r.try_get("status").unwrap_or_default(); + let notes: Option = r.try_get("notes").ok().flatten(); + let rejection_reason: Option = r.try_get("rejection_reason").ok().flatten(); + let updated_at: Option> = + r.try_get("updated_at").ok(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "has_verification": true, + "verification_id": id, + "status": status, + "document_request": notes, + "rejection_reason": rejection_reason, + "updated_at": updated_at, + })), + ) + .into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "has_verification": false, + "status": "NOT_SUBMITTED", + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +async fn fetch_saved_profile( + state: &AppState, + user_id: Uuid, + role_key: &str, +) -> serde_json::Value { + if role_key == "COMPANY" { + return match sqlx::query(r#"SELECT name FROM companies WHERE "userId" = $1"#) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(r)) => { + use sqlx::Row; + let name: Option = r.try_get("name").ok(); + serde_json::json!({ "company_name": name }) + } + _ => serde_json::Value::Object(Default::default()), + }; + } + + if let Some(table) = role_to_table(role_key) { + let q = format!(r#"SELECT "profileData" FROM {} WHERE user_id = $1"#, table); + if let Ok(Some(row)) = sqlx::query(&q) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + use sqlx::Row; + return row + .try_get::("profileData") + .unwrap_or(serde_json::Value::Object(Default::default())); + } + } + + serde_json::Value::Object(Default::default()) +} + +async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, status: &str) { + if role_key == "COMPANY" { + sqlx::query( + r#"UPDATE companies SET status = $1, "updatedAt" = NOW() WHERE "userId" = $2"#, + ) + .bind(status) + .bind(user_id) + .execute(&state.pool) + .await + .ok(); + return; + } + + if let Some(table) = role_to_table(role_key) { + let q = format!( + "UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE user_id = $2", + table + ); + sqlx::query(&q) + .bind(status) + .bind(user_id) + .execute(&state.pool) + .await + .ok(); + } +} diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index 17f15d7..bbf9f4a 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -19,6 +19,7 @@ pub fn router() -> Router { .route("/{id}/approve", post(approve_verification)) .route("/{id}/reject", post(reject_verification)) .route("/{id}/notes", post(add_notes)) + .route("/{id}/request-documents", post(request_documents)) } #[derive(Deserialize)] @@ -146,7 +147,7 @@ async fn trigger_activation( } // Assign role to user in user_roles - if let Ok(Some(role)) = RoleRepository::get_by_key(&state.pool, &role_key).await { + if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { sqlx::query!( "INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()", user_id, @@ -162,7 +163,7 @@ async fn trigger_activation( ).execute(&state.pool).await.ok(); // Get wallet id for ledger entry - if let Ok(Some(wallet_id)) = sqlx::query_scalar!( + if let Some(wallet_id) = sqlx::query_scalar!( "SELECT id FROM tracecoin_wallets WHERE user_id = $1", user_id ).fetch_optional(&state.pool).await.ok().flatten() { @@ -293,6 +294,8 @@ async fn reject_verification( } } +/// POST /api/admin/verifications/:id/notes +/// Adds internal notes without changing status (for reviewer comments). async fn add_notes( auth: AuthUser, State(state): State, @@ -307,23 +310,61 @@ async fn add_notes( match VerificationRepository::update_status( &state.pool, id, - "UNDER_REVIEW", // Or keep current status? + "UNDER_REVIEW", Some(auth.user_id), Some(¬es), None, ) .await + { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +#[derive(serde::Deserialize)] +pub struct RequestDocumentsPayload { + /// Human-readable message to the user describing what's needed. + pub message: String, +} + +/// POST /api/admin/verifications/:id/request-documents +/// Sets status to DOCUMENTS_REQUESTED and notifies the user. +async fn request_documents( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } + + match VerificationRepository::update_status( + &state.pool, + id, + "DOCUMENTS_REQUESTED", + Some(auth.user_id), + Some(&payload.message), + None, + ) + .await { Ok(v) => { - // Notify user that admin added notes/requested documents + // Notify the user sqlx::query!( - "INSERT INTO notifications (user_id, title, body, type, reference_id) VALUES ($1, $2, $3, $4, $5)", + r#"INSERT INTO notifications (user_id, title, body, type, reference_id) + VALUES ($1, $2, $3, $4, $5)"#, v.user_id, - "Action Required", - format!("Admin requested: {}", notes), + "Action Required — Documents Needed", + format!("Please resubmit your documents: {}", payload.message), "DOCUMENT_REQUEST", v.id - ).execute(&state.pool).await.ok(); + ) + .execute(&state.pool) + .await + .ok(); + (StatusCode::OK, Json(v)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 17a2279..71d465e 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -65,9 +65,12 @@ async fn main() { // ── Admin: Approvals (jobs/requirements) ───────────────────────── .nest("/api/admin/approvals", handlers::approvals::router()) .nest("/api/admin/verifications", handlers::verifications::router()) - // ── Me: Profile Status ───────────────────────────────────────────── + // ── Me: Profile Status + Verification Status ────────────────────── .nest("/api/me", handlers::onboarding::me_router()) - // ── Onboarding State (user-facing) ──────────────────────────────── + .nest("/api/me", handlers::profile::me_verification_router()) + // ── Profile (save + submit-for-verification) ────────────────────── + .nest("/api/profile", handlers::profile::router()) + // ── Onboarding State (legacy, kept for compatibility) ──────────── .nest("/api/onboarding", handlers::onboarding::onboarding_router()) // ── Admin: Onboarding + Dashboard Config ────────────────────────── .nest("/api/admin/onboarding-config", handlers::config::onboarding_router())