feat: extend admin/user flows with settings, verification, and approval updates
This commit is contained in:
parent
23c2edd567
commit
2ded64e71b
18 changed files with 1088 additions and 103 deletions
29
.cargo/config.toml
Normal file
29
.cargo/config.toml
Normal 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
113
.github/workflows/ci.yml
vendored
Normal 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'
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
265
apps/users/src/handlers/settings.rs
Normal file
265
apps/users/src/handlers/settings.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
37
cargo-deny.toml
Normal 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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS account_deletion_requests;
|
||||||
|
DROP TABLE IF EXISTS user_settings;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
37
load-tests/api-health.js
Normal 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);
|
||||||
|
}
|
||||||
99
load-tests/critical-flows.js
Normal file
99
load-tests/critical-flows.js
Normal 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`);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue