diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b3e027e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,29 @@ +[build] +rustdoc = false +incremental = false + +[profile.ci] +inherits = "release" +debug = true +split-debuginfo = "unpacked" + +[profile.coverage] +inherits = "ci" +opt-level = 0 +debug-assertions = true +overflow-checks = true + +[unstable] +profile-generate = "profile-generate" +profile-use = "profile-use" + +[term] +color = "auto" + +[net] +git-fetch-with-cli = true + +# For cargo-nextest +[profile.nextest] +inherits = "test" +debug = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5b5019a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: Backend CI + +on: + pull_request: + branches: [high-performance] + push: + branches: [high-performance] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: nxtgauge + POSTGRES_PASSWORD: nxtgauge_dev + POSTGRES_DB: nxtgauge_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: ['5432:5432'] + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: ['6379:6379'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + + - name: Install test tools + run: | + cargo install cargo-nextest cargo-llvm-cov cargo-deny + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Run cargo-deny (dependency check) + run: cargo deny check + + - name: Build + run: cargo build --workspace + + - name: Unit tests with nextest + run: cargo nextest run --workspace --cargo-extra-args="--all-features" + + # Integration tests require DB up; run with scripts/init-db.sql + - name: Initialize database + env: + DATABASE_URL: postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + run: | + psql $DATABASE_URL -f scripts/init-db.sql + + - name: Integration tests + env: + DATABASE_URL: postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + REDIS_URL: redis://localhost:6379 + JWT_SECRET: testsecret + run: cargo nextest run --workspace --test '*' --cargo-extra-args="--all-features" + + - name: Generate coverage report + env: + DATABASE_URL: postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + run: | + cargo llvm-cov nextest --workspace --all-features --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: lcov.info + fail_ci_if_error: false + + - name: Archive load-test script (k6) + run: tar -czf load-tests.tar.gz load-tests/ + + - name: Upload load-test script artifact + uses: actions/upload-artifact@v4 + with: + name: load-tests + path: load-tests.tar.gz + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Trivy + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + format: 'sarif' + output: 'trivy-results.sarif' + - name: Upload Trivy results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 7267c35..bf5f478 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -12,6 +12,7 @@ use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload}; use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload}; use db::models::application::ApplicationRepository; use db::models::user::UserRepository; +use db::models::verification::VerificationRepository; use contracts::auth_middleware::AuthUser; use crate::AppState; @@ -85,7 +86,15 @@ async fn submit_for_verification( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; - if company.status == "PENDING_REVIEW" || company.status == "APPROVED" { + if matches!( + company.status.as_str(), + "PENDING_REVIEW" + | "PENDING" + | "UNDER_REVIEW" + | "DOCUMENTS_REQUESTED" + | "REVISION_REQUESTED" + | "APPROVED" + ) { return (StatusCode::BAD_REQUEST, format!("Profile is already {}", company.status)).into_response(); } @@ -251,6 +260,28 @@ async fn submit_job( if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { let _ = state.mail.send_job_submitted_email(&user.email, user.full_name.as_deref().unwrap_or("User"), &updated.title).await; } + + // Create verification case so the request appears in Verification Management first. + let verification_payload = serde_json::json!({ + "entity_type": "JOB", + "entity_id": updated.id, + "title": updated.title, + "category": updated.category, + "location": updated.location, + "job_type": updated.job_type, + "status": updated.status, + "company_id": updated.company_id, + }); + let _ = VerificationRepository::create( + &state.pool, + auth.user_id, + "COMPANY", + "JOB_APPROVAL", + "MEDIUM", + verification_payload, + serde_json::json!([]), + ) + .await; (StatusCode::OK, Json(updated)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 1e0ce76..97e7e21 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -12,6 +12,7 @@ use db::models::professional::ProfessionalRepository; use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload}; use db::models::lead_request::LeadRequestRepository; use db::models::user::UserRepository; +use db::models::verification::VerificationRepository; use contracts::auth_middleware::AuthUser; use crate::AppState; @@ -82,7 +83,15 @@ async fn submit_for_verification( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; - if customer.status == "PENDING_REVIEW" || customer.status == "APPROVED" { + if matches!( + customer.status.as_str(), + "PENDING_REVIEW" + | "PENDING" + | "UNDER_REVIEW" + | "DOCUMENTS_REQUESTED" + | "REVISION_REQUESTED" + | "APPROVED" + ) { return (StatusCode::BAD_REQUEST, format!("Profile is already {}", customer.status)).into_response(); } @@ -221,6 +230,28 @@ async fn submit_requirement( if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { let _ = state.mail.send_requirement_submitted_email(&user.email, user.full_name.as_deref().unwrap_or("User"), &updated.title).await; } + + // Create verification case so this request enters Verification Management first. + let verification_payload = serde_json::json!({ + "entity_type": "REQUIREMENT", + "entity_id": updated.id, + "title": updated.title, + "profession_key": updated.profession_key, + "location": updated.location, + "budget": updated.budget, + "status": updated.status, + "customer_id": updated.customer_id, + }); + let _ = VerificationRepository::create( + &state.pool, + auth.user_id, + "CUSTOMER", + "REQUIREMENT_APPROVAL", + "MEDIUM", + verification_payload, + serde_json::json!([]), + ) + .await; (StatusCode::OK, Json(updated)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index b779592..ff14518 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -78,6 +78,9 @@ impl Services { // Auth, users, roles, notifications, runtime-config, config, KB, support if path.starts_with("/api/auth") || path.starts_with("/api/users") + || path.starts_with("/api/me") + || path.starts_with("/api/profile") + || path.starts_with("/api/onboarding") || path.starts_with("/api/roles") || path.starts_with("/api/notifications") || path.starts_with("/api/config") diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 021378d..32e29be 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -330,7 +330,15 @@ async fn submit_for_verification( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; - if seeker.status == "PENDING_REVIEW" || seeker.status == "APPROVED" { + if matches!( + seeker.status.as_str(), + "PENDING_REVIEW" + | "PENDING" + | "UNDER_REVIEW" + | "DOCUMENTS_REQUESTED" + | "REVISION_REQUESTED" + | "APPROVED" + ) { return (StatusCode::BAD_REQUEST, format!("Profile is already {}", seeker.status)).into_response(); } diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index 6a6722c..d1d3f06 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -10,7 +10,10 @@ use contracts::auth_middleware::{require_admin, AuthUser}; use db::models::activity_log::ActivityLogRepository; use db::models::job::JobRepository; use db::models::requirement::RequirementRepository; +use db::models::role::RoleRepository; use db::models::user::UserRepository; +use db::models::verification::Verification; +use db::models::verification::VerificationRepository; use serde::Deserialize; use uuid::Uuid; @@ -18,6 +21,8 @@ pub fn router() -> Router { Router::new() .route("/", get(list_pending)) .route("/submission/{user_id}", get(get_submission)) + .route("/profiles/{id}/approve", post(approve_profile)) + .route("/profiles/{id}/reject", post(reject_profile)) .route("/jobs/{id}/approve", post(approve_job)) .route("/jobs/{id}/reject", post(reject_job)) .route("/requirements/{id}/approve", post(approve_requirement)) @@ -46,6 +51,44 @@ async fn get_submission( Err(_) => return (StatusCode::NOT_FOUND, "User not found").into_response(), }; + let role_filter = q.role_key.as_deref().map(|s| s.to_uppercase()); + let latest_verification = sqlx::query_as::<_, Verification>( + r#" + SELECT * + FROM verifications + WHERE user_id = $1 + AND ($2::TEXT IS NULL OR UPPER(role_key) = UPPER($2)) + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(role_filter.as_deref()) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let submission = latest_verification.as_ref().map(|v| { + let completed_at = match v.status.as_str() { + "APPROVED" | "REJECTED" => Some(v.updated_at), + _ => None, + }; + serde_json::json!({ + "id": v.id, + "status": v.status, + "case_type": v.case_type, + "priority": v.priority, + "payload": v.payload, + "documents": v.documents, + "notes": v.notes, + "rejection_reason": v.rejection_reason, + "created_at": v.created_at, + "updated_at": v.updated_at, + "completed_at": completed_at, + }) + }); + ( StatusCode::OK, Json(serde_json::json!({ @@ -59,7 +102,10 @@ async fn get_submission( "created_at": user.created_at, }, "role_key": q.role_key, - "message": "Detailed submission data is now managed via the Verifications system.", + "submission": submission, + // Legacy compatibility for older admin UI code paths. + "onboarding": submission, + "message": "Submission details are sourced from Verification records.", })), ) .into_response() @@ -77,6 +123,28 @@ pub struct RejectPayload { pub reason: Option, } +async fn finalize_verification_case_for_entity( + pool: &sqlx::PgPool, + entity_id: Uuid, + case_type: &str, + final_status: &str, +) { + let _ = sqlx::query( + r#" + UPDATE verifications + SET status = $1, updated_at = NOW() + WHERE case_type = $2 + AND status = 'APPROVED' + AND payload->>'entity_id' = $3 + "#, + ) + .bind(final_status) + .bind(case_type) + .bind(entity_id.to_string()) + .execute(pool) + .await; +} + /// Deprecated: Use /api/admin/verifications instead. async fn list_pending( auth: AuthUser, @@ -97,6 +165,224 @@ async fn list_pending( .into_response() } +fn role_key_to_display(role_key: &str) -> String { + match role_key.to_uppercase().as_str() { + "COMPANY" => "Company".to_string(), + "CUSTOMER" => "Customer".to_string(), + "JOB_SEEKER" | "JOBSEEKER" => "Job Seeker".to_string(), + "PHOTOGRAPHER" => "Photographer".to_string(), + "MAKEUP_ARTIST" => "Makeup Artist".to_string(), + "TUTOR" => "Tutor".to_string(), + "DEVELOPER" => "Developer".to_string(), + "VIDEO_EDITOR" => "Video Editor".to_string(), + "GRAPHIC_DESIGNER" => "Graphic Designer".to_string(), + "SOCIAL_MEDIA_MANAGER" => "Social Media Manager".to_string(), + "FITNESS_TRAINER" => "Fitness Trainer".to_string(), + "CATERING_SERVICES" => "Catering Services".to_string(), + _ => role_key.to_string(), + } +} + +async fn activate_profile_after_final_approval( + state: &AppState, + user_id: Uuid, + role_key: &str, +) -> Result<(), sqlx::Error> { + let role_key = role_key.to_uppercase(); + let table = match role_key.as_str() { + "COMPANY" => "company_profiles", + "CUSTOMER" => "customer_profiles", + "JOB_SEEKER" | "JOBSEEKER" => "job_seeker_profiles", + "PHOTOGRAPHER" => "photographer_profiles", + "MAKEUP_ARTIST" => "makeup_artist_profiles", + "TUTOR" => "tutor_profiles", + "DEVELOPER" => "developer_profiles", + "VIDEO_EDITOR" => "video_editor_profiles", + "GRAPHIC_DESIGNER" => "graphic_designer_profiles", + "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", + "FITNESS_TRAINER" => "fitness_trainer_profiles", + "CATERING_SERVICES" => "catering_service_profiles", + _ => return Ok(()), + }; + + let query = format!( + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", + table + ); + sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + + sqlx::query!( + "UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'", + user_id + ) + .execute(&state.pool) + .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, + role.id + ) + .execute(&state.pool) + .await + .ok(); + } + + if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { + let display = role_key_to_display(&role_key); + let _ = state + .mail + .send_approval_approved_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + &display, + ) + .await; + } + + Ok(()) +} + +async fn reject_profile_after_final_approval( + state: &AppState, + user_id: Uuid, + role_key: &str, + reason: Option<&str>, +) -> Result<(), sqlx::Error> { + let role_key = role_key.to_uppercase(); + let table = match role_key.as_str() { + "COMPANY" => "company_profiles", + "CUSTOMER" => "customer_profiles", + "JOB_SEEKER" | "JOBSEEKER" => "job_seeker_profiles", + "PHOTOGRAPHER" => "photographer_profiles", + "MAKEUP_ARTIST" => "makeup_artist_profiles", + "TUTOR" => "tutor_profiles", + "DEVELOPER" => "developer_profiles", + "VIDEO_EDITOR" => "video_editor_profiles", + "GRAPHIC_DESIGNER" => "graphic_designer_profiles", + "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", + "FITNESS_TRAINER" => "fitness_trainer_profiles", + "CATERING_SERVICES" => "catering_service_profiles", + _ => return Ok(()), + }; + + let query = format!( + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", + table + ); + sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + + if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { + let display = role_key_to_display(&role_key); + let _ = state + .mail + .send_approval_rejected_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + &display, + reason.unwrap_or("Rejected by final approval"), + ) + .await; + } + + Ok(()) +} + +async fn approve_profile( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } + + let verification = match VerificationRepository::get_by_id(&state.pool, id).await { + Ok(Some(v)) => v, + Ok(None) => return (StatusCode::NOT_FOUND, "Verification not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + }; + + if verification.status != "APPROVED" { + return ( + StatusCode::BAD_REQUEST, + "Verification must be APPROVED in Verification Management before final approval", + ) + .into_response(); + } + + match activate_profile_after_final_approval(&state, verification.user_id, &verification.role_key).await { + Ok(_) => {} + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + } + + match VerificationRepository::update_status( + &state.pool, + id, + "COMPLETED", + Some(auth.user_id), + Some("Final approval completed"), + None, + ) + .await + { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + } +} + +async fn reject_profile( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } + + let verification = match VerificationRepository::get_by_id(&state.pool, id).await { + Ok(Some(v)) => v, + Ok(None) => return (StatusCode::NOT_FOUND, "Verification not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + }; + + if verification.status != "APPROVED" { + return ( + StatusCode::BAD_REQUEST, + "Verification must be APPROVED in Verification Management before final rejection", + ) + .into_response(); + } + + match reject_profile_after_final_approval( + &state, + verification.user_id, + &verification.role_key, + payload.reason.as_deref(), + ) + .await + { + Ok(_) => {} + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + } + + match VerificationRepository::update_status( + &state.pool, + id, + "FINAL_REJECTED", + Some(auth.user_id), + Some("Final approval rejected"), + payload.reason.as_deref(), + ) + .await + { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), + } +} + async fn approve_job( auth: AuthUser, State(state): State, @@ -139,6 +425,7 @@ async fn approve_job( if let Ok(Some((name, email))) = company_info { let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; } + finalize_verification_case_for_entity(&state.pool, id, "JOB_APPROVAL", "COMPLETED").await; (StatusCode::OK, Json(job)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), @@ -189,6 +476,7 @@ async fn reject_job( let r = payload.reason.as_deref().unwrap_or("Rejected by admin"); let _ = state.mail.send_job_rejected_email(&email, &name, &existing.title, r).await; } + finalize_verification_case_for_entity(&state.pool, id, "JOB_APPROVAL", "FINAL_REJECTED").await; (StatusCode::OK, Json(job)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), @@ -226,6 +514,7 @@ async fn approve_requirement( None, ) .await; + finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "COMPLETED").await; (StatusCode::OK, Json(req)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), @@ -254,6 +543,7 @@ async fn reject_requirement( Some(serde_json::json!({ "reason": payload.reason })), ) .await; + finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "FINAL_REJECTED").await; (StatusCode::OK, Json(req)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 7365c2a..7cee26e 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -38,6 +38,8 @@ pub struct RegisterPayload { pub email: String, pub phone: Option, pub password: String, + pub intent: Option, + pub profession: Option, } #[derive(Deserialize)] @@ -120,6 +122,33 @@ fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json String { + raw.trim().to_uppercase().replace(['-', ' '], "_") +} + +fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec { + let normalized_intent = normalize_role_key(intent.unwrap_or("JOB_SEEKER")); + let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty()); + + if normalized_intent.contains("COMPANY") { + return vec!["COMPANY".to_string()]; + } + if normalized_intent.contains("CUSTOMER") { + return vec!["CUSTOMER".to_string()]; + } + if normalized_intent.contains("JOB_SEEKER") || normalized_intent.contains("JOBSEEKER") { + return vec!["JOB_SEEKER".to_string()]; + } + if normalized_intent.contains("PROFESSIONAL") { + if let Some(p) = normalized_profession { + return vec![p, "PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; + } + return vec!["PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; + } + + vec!["JOB_SEEKER".to_string()] +} + // ── Handlers ────────────────────────────────────────────────────────────────── /// POST /api/auth/check-email @@ -185,6 +214,38 @@ async fn register( } })?; + // Assign signup role immediately (intent-driven). Email verification is still required for login. + let role_candidates = resolve_signup_role_candidates( + payload.intent.as_deref(), + payload.profession.as_deref(), + ); + for role_key in role_candidates { + let role = sqlx::query!( + "SELECT id FROM roles WHERE key = $1", + role_key + ) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + if let Some(role_row) = role { + let _ = sqlx::query!( + r#" + 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, + role_row.id + ) + .execute(&state.pool) + .await; + break; + } + } + // Store OTP in Redis (15-min TTL, keyed by code → user_id) let otp = format!("{:06}", rand::random::() % 1_000_000); cache::otp::set(&mut redis, &otp, &user.id.to_string()) diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index e7547e5..f08ee7e 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -17,3 +17,4 @@ pub mod user_roles; pub mod external_roles; pub mod verifications; pub mod profile; +pub mod settings; diff --git a/apps/users/src/handlers/settings.rs b/apps/users/src/handlers/settings.rs new file mode 100644 index 0000000..4984030 --- /dev/null +++ b/apps/users/src/handlers/settings.rs @@ -0,0 +1,265 @@ +use crate::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, patch}, + Json, Router, +}; +use contracts::auth_middleware::AuthUser; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/", get(get_settings)) + .route("/notifications", patch(update_notifications)) + .route("/delete-account-request", get(get_delete_account_request).post(create_delete_account_request)) +} + +#[derive(Serialize)] +struct SettingsResponse { + email_notifications: bool, + in_app_notifications: bool, + sms_notifications: bool, +} + +#[derive(Deserialize)] +struct UpdateNotificationsPayload { + email_notifications: bool, + in_app_notifications: bool, + sms_notifications: bool, +} + +#[derive(Deserialize)] +struct CreateDeleteAccountPayload { + reason: Option, +} + +#[derive(Serialize)] +struct DeleteRequestResponse { + status: String, + deleted_at: Option, +} + +async fn ensure_settings_row(pool: &sqlx::PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO user_settings (user_id) + VALUES ($1) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +async fn get_settings( + auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + if let Err(e) = ensure_settings_row(&state.pool, auth.user_id).await { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + + let row = sqlx::query_as::<_, (bool, bool, bool)>( + r#" + SELECT email_notifications, in_app_notifications, sms_notifications + FROM user_settings + WHERE user_id = $1 + "#, + ) + .bind(auth.user_id) + .fetch_one(&state.pool) + .await; + + match row { + Ok((email_notifications, in_app_notifications, sms_notifications)) => ( + StatusCode::OK, + Json(SettingsResponse { + email_notifications, + in_app_notifications, + sms_notifications, + }), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_notifications( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let result = sqlx::query( + r#" + INSERT INTO user_settings (user_id, email_notifications, in_app_notifications, sms_notifications, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET + email_notifications = EXCLUDED.email_notifications, + in_app_notifications = EXCLUDED.in_app_notifications, + sms_notifications = EXCLUDED.sms_notifications, + updated_at = NOW() + "#, + ) + .bind(auth.user_id) + .bind(payload.email_notifications) + .bind(payload.in_app_notifications) + .bind(payload.sms_notifications) + .execute(&state.pool) + .await; + + match result { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Notification preferences updated" + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_delete_account_request( + auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let row = sqlx::query_as::<_, (Option>, )>( + r#" + SELECT deleted_at + FROM users + WHERE id = $1 + "#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await; + + match row { + Ok(Some((deleted_at,))) if deleted_at.is_some() => ( + StatusCode::OK, + Json(DeleteRequestResponse { + status: "DELETED".to_string(), + deleted_at: deleted_at.map(|v| v.to_rfc3339()), + }), + ) + .into_response(), + Ok(Some((_deleted_at,))) => ( + StatusCode::OK, + Json(DeleteRequestResponse { + status: "NONE".to_string(), + deleted_at: None, + }), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "User not found" })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn create_delete_account_request( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let user = match db::models::user::UserRepository::get_by_id(&state.pool, auth.user_id).await { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "User not found" })), + ) + .into_response() + } + }; + + let already_deleted = sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*)::BIGINT + FROM users + WHERE id = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(auth.user_id) + .fetch_one(&state.pool) + .await; + + if let Ok(count) = already_deleted { + if count > 0 { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Account is already deleted." + })), + ) + .into_response(); + } + } + + let deleted_at = chrono::Utc::now(); + let soft_deleted = sqlx::query( + r#" + UPDATE users + SET deleted_at = $2, status = 'SUSPENDED', updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(auth.user_id) + .bind(deleted_at) + .execute(&state.pool) + .await; + + match soft_deleted { + Ok(result) if result.rows_affected() > 0 => { + let _ = db::models::user::UserRepository::revoke_all_for_user(&state.pool, auth.user_id).await; + let _ = state + .mail + .send_account_deleted_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + ) + .await; + let _ = sqlx::query!( + r#"INSERT INTO notifications (user_id, title, body, type) + VALUES ($1, $2, $3, $4)"#, + auth.user_id, + "Account Deleted", + format!( + "Your account was deleted{}.", + payload + .reason + .as_deref() + .map(|r| format!(" Reason: {r}")) + .unwrap_or_default() + ), + "ACCOUNT" + ) + .execute(&state.pool) + .await; + + ( + StatusCode::OK, + Json(DeleteRequestResponse { + status: "DELETED".to_string(), + deleted_at: Some(deleted_at.to_rfc3339()), + }), + ) + .into_response() + } + Ok(_) => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "Account is already deleted." })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index bbf9f4a..e6d9ddb 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -7,7 +7,6 @@ use axum::{ Json, Router, }; use contracts::auth_middleware::{require_admin, AuthUser}; -use db::models::role::RoleRepository; use db::models::verification::{VerificationRepository}; use serde::Deserialize; use uuid::Uuid; @@ -20,6 +19,7 @@ pub fn router() -> Router { .route("/{id}/reject", post(reject_verification)) .route("/{id}/notes", post(add_notes)) .route("/{id}/request-documents", post(request_documents)) + .route("/{id}/request-revision", post(request_revision)) } #[derive(Deserialize)] @@ -96,99 +96,6 @@ fn role_key_to_display(role_key: &str) -> String { } } -async fn trigger_activation( - state: &AppState, - user_id: Uuid, - role_key: &str, - case_type: &str, -) -> Result<(), sqlx::Error> { - let role_key = role_key.to_uppercase(); - - // For Profile Verifications, update the corresponding profile table - if case_type == "PROFILE_VERIFICATION" { - let table = match role_key.as_str() { - "COMPANY" => "company_profiles", - "CUSTOMER" => "customer_profiles", - "JOB_SEEKER" | "JOBSEEKER" => "job_seeker_profiles", - "PHOTOGRAPHER" => "photographer_profiles", - "MAKEUP_ARTIST" => "makeup_artist_profiles", - "TUTOR" => "tutor_profiles", - "DEVELOPER" => "developer_profiles", - "VIDEO_EDITOR" => "video_editor_profiles", - "GRAPHIC_DESIGNER" => "graphic_designer_profiles", - "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", - "FITNESS_TRAINER" => "fitness_trainer_profiles", - "CATERING_SERVICES" => "catering_service_profiles", - _ => return Ok(()), // Unknown role, skip - }; - - let query = format!( - "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", - table - ); - sqlx::query(&query).bind(user_id).execute(&state.pool).await?; - - // Also update the global user status if it's currently PENDING - sqlx::query!( - "UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'", - user_id - ) - .execute(&state.pool) - .await?; - - // Send Email - if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { - let display = role_key_to_display(&role_key); - let _ = state.mail.send_approval_approved_email( - &user.email, - user.full_name.as_deref().unwrap_or_default(), - &display - ).await; - } - - // Assign role to user in user_roles - 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, - role.id - ).execute(&state.pool).await.ok(); - } - - // Credit 250 promotional tracecoins - sqlx::query!( - "INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, $2, 0) ON CONFLICT (user_id) DO UPDATE SET balance = tracecoin_wallets.balance + excluded.balance", - user_id, - 250 - ).execute(&state.pool).await.ok(); - - // Get wallet id for ledger entry - 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() { - sqlx::query!( - "INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)", - wallet_id, - 250, - "PROMOTIONAL_SIGNUP_BONUS", - user_id - ).execute(&state.pool).await.ok(); - } - - // Create notification for user - sqlx::query!( - "INSERT INTO notifications (user_id, title, body, type) VALUES ($1, $2, $3, $4)", - user_id, - "Profile Approved", - format!("Your {} profile has been approved.", role_key_to_display(&role_key)), - "APPROVAL" - ).execute(&state.pool).await.ok(); - } - - Ok(()) -} - async fn trigger_rejection( state: &AppState, user_id: Uuid, @@ -257,11 +164,7 @@ async fn approve_verification( ) .await { - Ok(v) => { - // Trigger actual role activation - let _ = trigger_activation(&state, v.user_id, &v.role_key, &v.case_type).await; - (StatusCode::OK, Json(v)).into_response() - } + Ok(v) => (StatusCode::OK, Json(v)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -370,3 +273,45 @@ async fn request_documents( Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } + +/// POST /api/admin/verifications/:id/request-revision +/// Sets status to REVISION_REQUESTED and notifies the user. +async fn request_revision( + 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, + "REVISION_REQUESTED", + Some(auth.user_id), + Some(&payload.message), + None, + ) + .await + { + Ok(v) => { + sqlx::query!( + r#"INSERT INTO notifications (user_id, title, body, type, reference_id) + VALUES ($1, $2, $3, $4, $5)"#, + v.user_id, + "Action Required — Revision Requested", + format!("Please revise your submission: {}", payload.message), + "REVISION_REQUEST", + v.id + ) + .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 00bcf1f..f808357 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -62,6 +62,7 @@ async fn main() { .nest("/api/me/roles", handlers::user_roles::router()) // ── Notifications ───────────────────────────────────────────────── .nest("/api/me/notifications", handlers::notifications::router()) + .nest("/api/me/settings", handlers::settings::router()) // ── Admin: Approvals (jobs/requirements) ───────────────────────── .nest("/api/admin/approvals", handlers::approvals::router()) .nest("/api/admin/verifications", handlers::verifications::router()) diff --git a/cargo-deny.toml b/cargo-deny.toml new file mode 100644 index 0000000..a6f5f9b --- /dev/null +++ b/cargo-deny.toml @@ -0,0 +1,37 @@ +[graphics] +target-all = false + +[source.crates-io] +replace-with = "crates-io-sparse" +[source."https://github.com/rust-lang/crates.io-index"] +replace-with = "crates-io-sparse" +[source.crates-io-sparse] +registry = "https://index.crates.io/" + +[licenses] +allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-DFS-2016", "0BSD"] +deny = ["GPL-2.0", "GPL-3.0", "AGPL-3.0"] +unlicensed = "deny" +private = { registries = [] } + +[bans] +multiple-versions = "warn" +wildcards = "allow" +# Deny certain crates known to be problematic +skip = [ + { name = "openssl-sys", versions = ["<=0.6.7"] }, + { name = "ring", versions = ["<=0.17.0"] }, +] +# Deny crates that are unmaintained or have known security issues +deny = [ + # Add specific problematic crates as needed +] + +[advisories] +ignore = [ + # Add advisory IDs to ignore (use sparingly) +] + +[metadata] +# Allow yanked crates only for specific reasons +allow-yanked = false diff --git a/crates/db/migrations/20260408120000_user_settings_and_account_deletion.down.sql b/crates/db/migrations/20260408120000_user_settings_and_account_deletion.down.sql new file mode 100644 index 0000000..b0ab758 --- /dev/null +++ b/crates/db/migrations/20260408120000_user_settings_and_account_deletion.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS account_deletion_requests; +DROP TABLE IF EXISTS user_settings; diff --git a/crates/db/migrations/20260408120000_user_settings_and_account_deletion.up.sql b/crates/db/migrations/20260408120000_user_settings_and_account_deletion.up.sql new file mode 100644 index 0000000..ff96645 --- /dev/null +++ b/crates/db/migrations/20260408120000_user_settings_and_account_deletion.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS user_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + email_notifications BOOLEAN NOT NULL DEFAULT TRUE, + in_app_notifications BOOLEAN NOT NULL DEFAULT TRUE, + sms_notifications BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS account_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'PENDING', + reason TEXT, + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_deletion_requests_user_id + ON account_deletion_requests(user_id); diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index ed1ef1f..ca9cc6e 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -116,6 +116,17 @@ impl Mailer { ).await } + pub async fn send_account_deleted_email(&self, to: &str, name: &str) -> Result<()> { + self.send( + to, + "Your NXTGAUGE account has been deleted", + format!( + "Hello {},\n\nYour account has been deleted as requested. If this was not you, please contact support immediately.\n\nRegards,\nThe NXTGAUGE Team", + name + ), + ).await + } + // ── Onboarding & Approvals ──────────────────────────────────────────────── pub async fn send_onboarding_submitted_email(&self, to: &str, name: &str, role: &str) -> Result<()> { diff --git a/load-tests/api-health.js b/load-tests/api-health.js new file mode 100644 index 0000000..75fb1c9 --- /dev/null +++ b/load-tests/api-health.js @@ -0,0 +1,37 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export let options = { + stages: [ + { duration: '30s', target: 10 }, // ramp up to 10 users + { duration: '1m', target: 10 }, // stay at 10 users + { duration: '30s', target: 20 }, // ramp up to 20 + { duration: '1m', target: 20 }, // stay + { duration: '30s', target: 0 }, // ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + http_req_failed: ['rate<0.01'], // error rate < 1% + }, +}; + +const BASE_URL = 'http://localhost:8000'; + +export default function () { + // Health check + let res = http.get(`${BASE_URL}/health`); + check(res, { 'health 200': (r) => r.status === 200 }); + + // Admin endpoints (requires auth - we test 401 to ensure auth is enforced) + res = http.get(`${BASE_URL}/api/admin/companies`); + check(res, { 'companies 401': (r) => r.status === 401 }); + + res = http.get(`${BASE_URL}/api/admin/users`); + check(res, { 'users 401': (r) => r.status === 401 }); + + // Public endpoints + res = http.get(`${BASE_URL}/api/users/public`); + check(res, { 'public users ok': (r) => r.status === 200 || r.status === 404 }); + + sleep(1); +} diff --git a/load-tests/critical-flows.js b/load-tests/critical-flows.js new file mode 100644 index 0000000..22c852a --- /dev/null +++ b/load-tests/critical-flows.js @@ -0,0 +1,99 @@ +import http from 'k6/http'; +import { check, sleep, Trend } from 'k6'; +import { Rate } from 'k6/metrics'; + +export let options = { + stages: [ + { duration: '1m', target: 20 }, // warm-up + { duration: '3m', target: 50 }, // peak load + { duration: '1m', target: 0 }, // ramp down + ], + thresholds: { + http_req_duration: [ + { threshold: 'p(95)<800', abortOnFail: true }, + { threshold: 'p(99)<2000', abortOnFail: false }, + ], + 'api success rate': ['rate>0.99'], + }, + noConnectionReuse: false, + maxRedirects: 5, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000'; +const ADMIN_TOKEN = __ENV.ADMIN_TOKEN || ''; // optional + +// Custom metrics +const loginTrend = new Trend('login_duration'); +const registerTrend = new Trend('register_duration'); +const jobsTrend = new Trend('jobs_list_duration'); +const leadsTrend = new Trend('leads_list_duration'); + +// Helper: get auth headers +function authHeaders() { + return ADMIN_TOKEN ? { Authorization: `Bearer ${ADMIN_TOKEN}` } : {}; +} + +export default function () { + // 1. Login flow (simulate admin) + let loginRes = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ + email: 'admin@example.com', + password: 'test123', + loginTarget: 'admin', + }), + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-portal-target': 'admin', + }, + } + ); + loginTrend.add(loginRes.timings.duration); + let success = check(loginRes, { + 'login 200': (r) => r.status === 200, + 'login has token': (r) => { + const t = r.json('token'); + return t !== undefined && t !== null; + }, + }); + + if (success) { + const body = loginRes.json(); + const token = body.token; + const headers = { ...authHeaders(), Authorization: `Bearer ${token}` }; + + // 2. List companies + let res = http.get(`${BASE_URL}/api/admin/companies`, { headers }); + check(res, { 'companies 200': (r) => r.status === 200 }); + jobsTrend.add(res.timings.duration); + + // 3. List jobs (via companies service) + res = http.get(`${BASE_URL}/api/admin/companies/jobs`, { headers }); + check(res, { 'jobs 200': (r) => r.status === 200 }); + leadsTrend.add(res.timings.duration); + + // 4. Get single company detail + if (res.json().length > 0) { + const firstJob = res.json()[0]; + if (firstJob.id) { + http.get(`${BASE_URL}/api/admin/companies/jobs/${firstJob.id}`, { headers }); + } + } + + // 5. List leads + res = http.get(`${BASE_URL}/api/admin/leads`, { headers }); + check(res, { 'leads 200': (r) => r.status === 200 }); + leadsTrend.add(res.timings.duration); + } + + sleep(2); +} + +// Summarize metrics at end +export function teardown() { + console.log(`Login avg: ${loginTrend.avg.toFixed(2)}ms`); + console.log(`Jobs list avg: ${jobsTrend.avg.toFixed(2)}ms`); + console.log(`Leads list avg: ${leadsTrend.avg.toFixed(2)}ms`); +}