feat: extend admin/user flows with settings, verification, and approval updates

This commit is contained in:
Ashwin Kumar 2026-04-08 22:40:54 +02:00
parent 23c2edd567
commit 2ded64e71b
18 changed files with 1088 additions and 103 deletions

29
.cargo/config.toml Normal file
View file

@ -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

113
.github/workflows/ci.yml vendored Normal file
View file

@ -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'

View file

@ -12,6 +12,7 @@ use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload};
use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload}; use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload};
use db::models::application::ApplicationRepository; use db::models::application::ApplicationRepository;
use db::models::user::UserRepository; use db::models::user::UserRepository;
use db::models::verification::VerificationRepository;
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use crate::AppState; use crate::AppState;
@ -85,7 +86,15 @@ async fn submit_for_verification(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), 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(); 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 { 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; 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() (StatusCode::OK, Json(updated)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),

View file

@ -12,6 +12,7 @@ use db::models::professional::ProfessionalRepository;
use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload}; use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload};
use db::models::lead_request::LeadRequestRepository; use db::models::lead_request::LeadRequestRepository;
use db::models::user::UserRepository; use db::models::user::UserRepository;
use db::models::verification::VerificationRepository;
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use crate::AppState; use crate::AppState;
@ -82,7 +83,15 @@ async fn submit_for_verification(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), 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(); 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 { 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; 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() (StatusCode::OK, Json(updated)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),

View file

@ -78,6 +78,9 @@ impl Services {
// Auth, users, roles, notifications, runtime-config, config, KB, support // Auth, users, roles, notifications, runtime-config, config, KB, support
if path.starts_with("/api/auth") if path.starts_with("/api/auth")
|| path.starts_with("/api/users") || 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/roles")
|| path.starts_with("/api/notifications") || path.starts_with("/api/notifications")
|| path.starts_with("/api/config") || path.starts_with("/api/config")

View file

@ -330,7 +330,15 @@ async fn submit_for_verification(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), 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(); return (StatusCode::BAD_REQUEST, format!("Profile is already {}", seeker.status)).into_response();
} }

View file

@ -10,7 +10,10 @@ use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::activity_log::ActivityLogRepository; use db::models::activity_log::ActivityLogRepository;
use db::models::job::JobRepository; use db::models::job::JobRepository;
use db::models::requirement::RequirementRepository; use db::models::requirement::RequirementRepository;
use db::models::role::RoleRepository;
use db::models::user::UserRepository; use db::models::user::UserRepository;
use db::models::verification::Verification;
use db::models::verification::VerificationRepository;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -18,6 +21,8 @@ pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_pending)) .route("/", get(list_pending))
.route("/submission/{user_id}", get(get_submission)) .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}/approve", post(approve_job))
.route("/jobs/{id}/reject", post(reject_job)) .route("/jobs/{id}/reject", post(reject_job))
.route("/requirements/{id}/approve", post(approve_requirement)) .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(), 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, StatusCode::OK,
Json(serde_json::json!({ Json(serde_json::json!({
@ -59,7 +102,10 @@ async fn get_submission(
"created_at": user.created_at, "created_at": user.created_at,
}, },
"role_key": q.role_key, "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() .into_response()
@ -77,6 +123,28 @@ pub struct RejectPayload {
pub reason: Option<String>, pub reason: Option<String>,
} }
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. /// Deprecated: Use /api/admin/verifications instead.
async fn list_pending( async fn list_pending(
auth: AuthUser, auth: AuthUser,
@ -97,6 +165,224 @@ async fn list_pending(
.into_response() .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<AppState>,
Path(id): Path<Uuid>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> 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( async fn approve_job(
auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@ -139,6 +425,7 @@ async fn approve_job(
if let Ok(Some((name, email))) = company_info { if let Ok(Some((name, email))) = company_info {
let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; 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() (StatusCode::OK, Json(job)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).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 r = payload.reason.as_deref().unwrap_or("Rejected by admin");
let _ = state.mail.send_job_rejected_email(&email, &name, &existing.title, r).await; 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() (StatusCode::OK, Json(job)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(),
@ -226,6 +514,7 @@ async fn approve_requirement(
None, None,
) )
.await; .await;
finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "COMPLETED").await;
(StatusCode::OK, Json(req)).into_response() (StatusCode::OK, Json(req)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).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 })), Some(serde_json::json!({ "reason": payload.reason })),
) )
.await; .await;
finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "FINAL_REJECTED").await;
(StatusCode::OK, Json(req)).into_response() (StatusCode::OK, Json(req)).into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(),

View file

@ -38,6 +38,8 @@ pub struct RegisterPayload {
pub email: String, pub email: String,
pub phone: Option<String>, pub phone: Option<String>,
pub password: String, pub password: String,
pub intent: Option<String>,
pub profession: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -120,6 +122,33 @@ fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResp
})) }))
} }
fn normalize_role_key(raw: &str) -> String {
raw.trim().to_uppercase().replace(['-', ' '], "_")
}
fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec<String> {
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 ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
/// POST /api/auth/check-email /// 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) // Store OTP in Redis (15-min TTL, keyed by code → user_id)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000); let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
cache::otp::set(&mut redis, &otp, &user.id.to_string()) cache::otp::set(&mut redis, &otp, &user.id.to_string())

View file

@ -17,3 +17,4 @@ pub mod user_roles;
pub mod external_roles; pub mod external_roles;
pub mod verifications; pub mod verifications;
pub mod profile; pub mod profile;
pub mod settings;

View file

@ -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<AppState> {
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<String>,
}
#[derive(Serialize)]
struct DeleteRequestResponse {
status: String,
deleted_at: Option<String>,
}
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<AppState>,
) -> 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<AppState>,
Json(payload): Json<UpdateNotificationsPayload>,
) -> 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<AppState>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (Option<chrono::DateTime<chrono::Utc>>, )>(
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<AppState>,
Json(payload): Json<CreateDeleteAccountPayload>,
) -> 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(),
}
}

View file

@ -7,7 +7,6 @@ use axum::{
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::{require_admin, AuthUser}; use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::role::RoleRepository;
use db::models::verification::{VerificationRepository}; use db::models::verification::{VerificationRepository};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -20,6 +19,7 @@ pub fn router() -> Router<AppState> {
.route("/{id}/reject", post(reject_verification)) .route("/{id}/reject", post(reject_verification))
.route("/{id}/notes", post(add_notes)) .route("/{id}/notes", post(add_notes))
.route("/{id}/request-documents", post(request_documents)) .route("/{id}/request-documents", post(request_documents))
.route("/{id}/request-revision", post(request_revision))
} }
#[derive(Deserialize)] #[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( async fn trigger_rejection(
state: &AppState, state: &AppState,
user_id: Uuid, user_id: Uuid,
@ -257,11 +164,7 @@ async fn approve_verification(
) )
.await .await
{ {
Ok(v) => { Ok(v) => (StatusCode::OK, Json(v)).into_response(),
// Trigger actual role activation
let _ = trigger_activation(&state, v.user_id, &v.role_key, &v.case_type).await;
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).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(), 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RequestDocumentsPayload>,
) -> 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(),
}
}

View file

@ -62,6 +62,7 @@ async fn main() {
.nest("/api/me/roles", handlers::user_roles::router()) .nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ───────────────────────────────────────────────── // ── Notifications ─────────────────────────────────────────────────
.nest("/api/me/notifications", handlers::notifications::router()) .nest("/api/me/notifications", handlers::notifications::router())
.nest("/api/me/settings", handlers::settings::router())
// ── Admin: Approvals (jobs/requirements) ───────────────────────── // ── Admin: Approvals (jobs/requirements) ─────────────────────────
.nest("/api/admin/approvals", handlers::approvals::router()) .nest("/api/admin/approvals", handlers::approvals::router())
.nest("/api/admin/verifications", handlers::verifications::router()) .nest("/api/admin/verifications", handlers::verifications::router())

37
cargo-deny.toml Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS account_deletion_requests;
DROP TABLE IF EXISTS user_settings;

View file

@ -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);

View file

@ -116,6 +116,17 @@ impl Mailer {
).await ).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 ──────────────────────────────────────────────── // ── Onboarding & Approvals ────────────────────────────────────────────────
pub async fn send_onboarding_submitted_email(&self, to: &str, name: &str, role: &str) -> Result<()> { pub async fn send_onboarding_submitted_email(&self, to: &str, name: &str, role: &str) -> Result<()> {

37
load-tests/api-health.js Normal file
View file

@ -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);
}

View file

@ -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`);
}