- Delete apps/companies/src/handlers/ai.rs (broken placeholder code)
- Remove ai module export from handlers/mod.rs
- Remove /api/companies/ai route from main.rs
The broken stub had:
- Uuid::parse_str("placeholder") that always errored
- Uuid::new_v4() generating random IDs instead of using auth
- Queries to non-existent company_ai_credits/ai_usage_log tables
AI credits are now properly handled by the users service with
the new ai_credits module (wallet, ledger, LiteLLM integration).
750 lines
28 KiB
Rust
750 lines
28 KiB
Rust
pub mod admin;
|
|
|
|
use axum::{
|
|
extract::{Multipart, Path, Query, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
routing::{get, patch, post},
|
|
Json, Router,
|
|
};
|
|
use bytes::BufMut;
|
|
use cache::jobs as cache_jobs;
|
|
use redis::AsyncCommands;
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload};
|
|
use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload};
|
|
use db::models::application::ApplicationRepository;
|
|
use db::models::user::UserRepository;
|
|
use db::models::verification::VerificationRepository;
|
|
use contracts::auth_middleware::AuthUser;
|
|
use crate::AppState;
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/profile/me", get(get_profile).patch(update_profile))
|
|
.route("/profile/documents", post(upload_documents))
|
|
.route("/profile/submit", post(submit_for_verification))
|
|
.route("/jobs", get(list_jobs).post(create_job))
|
|
.route("/jobs/{id}", get(get_job).patch(update_job))
|
|
.route("/jobs/{id}/submit", post(submit_job))
|
|
.route("/jobs/{id}/close", post(close_job))
|
|
.route("/jobs/{id}/applications", get(list_applications))
|
|
.route("/applications/{id}/status", patch(update_application_status))
|
|
.route("/applications/{id}/contact", get(view_contact))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PaginationQuery {
|
|
pub page: Option<i64>,
|
|
pub limit: Option<i64>,
|
|
pub status: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateJobRequest {
|
|
pub title: String,
|
|
pub description: String,
|
|
pub location: String,
|
|
pub job_type: Option<String>,
|
|
pub salary_min: Option<i32>,
|
|
pub salary_max: Option<i32>,
|
|
pub experience_years: Option<i32>,
|
|
pub skills: Option<Vec<String>>,
|
|
pub category: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateApplicationStatusPayload {
|
|
pub status: String,
|
|
}
|
|
|
|
async fn get_profile(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let cache_key = format!("profile:company:{}", auth.user_id);
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Try cache first
|
|
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
|
|
tracing::debug!("Cache hit for company profile: {}", auth.user_id);
|
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
|
|
return (StatusCode::OK, Json(parsed)).into_response();
|
|
}
|
|
}
|
|
|
|
match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(profile)) => {
|
|
// Cache for 5 minutes
|
|
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await;
|
|
(StatusCode::OK, Json(profile)).into_response()
|
|
}
|
|
Ok(None) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn update_profile(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<UpsertCompanyProfilePayload>,
|
|
) -> impl IntoResponse {
|
|
match CompanyRepository::upsert(&state.pool, auth.user_id, payload).await {
|
|
Ok(profile) => {
|
|
// Invalidate profile cache
|
|
let cache_key = format!("profile:company:{}", auth.user_id);
|
|
let mut redis = state.redis.clone();
|
|
let _ = redis.del::<_, ()>(&cache_key).await;
|
|
(StatusCode::OK, Json(profile)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn submit_for_verification(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => return (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
match CompanyRepository::submit_for_verification(&state.pool, auth.user_id).await {
|
|
Ok(profile) => {
|
|
// Invalidate company profile cache
|
|
let cache_key = format!("profile:company:{}", auth.user_id);
|
|
let mut redis = state.redis.clone();
|
|
let _ = redis.del::<_, ()>(&cache_key).await;
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"status": profile.status,
|
|
"message": "Profile submitted for verification"
|
|
}))).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_jobs(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
let page = q.page.unwrap_or(1);
|
|
let limit = q.limit.unwrap_or(20);
|
|
let status_filter = q.status.as_deref().unwrap_or("");
|
|
|
|
// Build cache key
|
|
let cache_key = format!("jobs:company:{}:{}:{}:{}", company.id, page, limit, status_filter);
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Try cache first
|
|
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
|
|
tracing::debug!("Cache hit for company jobs: {}", cache_key);
|
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
|
|
return (StatusCode::OK, Json(parsed)).into_response();
|
|
}
|
|
}
|
|
|
|
match JobRepository::list_by_company_id(&state.pool, company.id, q.status, page, limit).await {
|
|
Ok(jobs) => {
|
|
let response = serde_json::json!({
|
|
"data": jobs,
|
|
"pagination": { "page": page, "limit": limit }
|
|
});
|
|
// Cache for 5 minutes
|
|
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&response).unwrap_or_default(), 300).await;
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn create_job(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<CreateJobRequest>,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
if company.status != "APPROVED" {
|
|
return (StatusCode::FORBIDDEN, "Company profile approval is required before posting jobs").into_response();
|
|
}
|
|
|
|
// --- New Quota Logic ---
|
|
let jobs_this_month = match JobRepository::count_by_company_id_this_month(&state.pool, company.id).await {
|
|
Ok(count) => count,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
if jobs_this_month >= 1 {
|
|
// Must use a purchased slot if they've already used their monthly freebie
|
|
if company.purchased_job_slots <= 0 {
|
|
return (
|
|
StatusCode::PAYMENT_REQUIRED,
|
|
Json(serde_json::json!({
|
|
"error": "Monthly free job quota exhausted. Please purchase job slots.",
|
|
"code": "QUOTA_EXHAUSTED",
|
|
"requires_tracecoins": true
|
|
}))
|
|
).into_response();
|
|
}
|
|
|
|
// Deduct ONE purchased slot
|
|
let deduct_result = sqlx::query(
|
|
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
|
|
)
|
|
.bind(company.id)
|
|
.execute(&state.pool)
|
|
.await;
|
|
|
|
if let Err(e) = deduct_result {
|
|
tracing::error!("Failed to deduct job slot: {}", e);
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to deduct quota").into_response();
|
|
}
|
|
}
|
|
// -----------------------
|
|
|
|
let db_payload = DbCreateJobPayload {
|
|
company_id: company.id,
|
|
title: payload.title,
|
|
category: payload.category,
|
|
description: payload.description,
|
|
location: payload.location,
|
|
job_type: payload.job_type,
|
|
salary_min: payload.salary_min,
|
|
salary_max: payload.salary_max,
|
|
experience_years: payload.experience_years,
|
|
skills: payload.skills,
|
|
};
|
|
|
|
match JobRepository::create(&state.pool, db_payload).await {
|
|
Ok(job) => {
|
|
// Invalidate company's job list cache
|
|
let mut redis = state.redis.clone();
|
|
let pattern = format!("jobs:company:{}:*", company.id);
|
|
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
|
|
if !keys.is_empty() {
|
|
let _ = redis.del::<_, ()>(keys).await;
|
|
}
|
|
}
|
|
(StatusCode::CREATED, Json(job)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_job(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
_auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
match JobRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(job)) => (StatusCode::OK, Json(job)).into_response(),
|
|
Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn update_job(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<DbUpdateJobPayload>,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
if company.status != "APPROVED" {
|
|
return (StatusCode::FORBIDDEN, "Company profile approval is required before submitting jobs").into_response();
|
|
}
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(j)) if j.company_id == company.id => j,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
|
};
|
|
|
|
match JobRepository::update(&state.pool, job.id, payload).await {
|
|
Ok(updated) => {
|
|
// Invalidate company job list cache
|
|
let mut redis = state.redis.clone();
|
|
let pattern = format!("jobs:company:{}:*", company.id);
|
|
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
|
|
if !keys.is_empty() {
|
|
let _ = redis.del::<_, ()>(keys).await;
|
|
}
|
|
}
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn submit_job(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(j)) if j.company_id == company.id => j,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
|
};
|
|
|
|
if job.status != "DRAFT" {
|
|
return (StatusCode::BAD_REQUEST, "Job already submitted or live").into_response();
|
|
}
|
|
|
|
match JobRepository::update_status(&state.pool, job.id, "PENDING_APPROVAL").await {
|
|
Ok(updated) => {
|
|
// Fire email to company user (ignore failures)
|
|
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
|
|
let _ = state.mail.send_job_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &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;
|
|
// Invalidate company job list cache
|
|
let mut redis = state.redis.clone();
|
|
let pattern = format!("jobs:company:{}:*", company.id);
|
|
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
|
|
if !keys.is_empty() {
|
|
let _ = redis.del::<_, ()>(keys).await;
|
|
}
|
|
}
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn close_job(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(j)) if j.company_id == company.id => j,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
|
};
|
|
|
|
match JobRepository::update_status(&state.pool, job.id, "CLOSED").await {
|
|
Ok(updated) => {
|
|
// Invalidate company job list cache
|
|
let mut redis = state.redis.clone();
|
|
let pattern = format!("jobs:company:{}:*", company.id);
|
|
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
|
|
if !keys.is_empty() {
|
|
let _ = redis.del::<_, ()>(keys).await;
|
|
}
|
|
}
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_applications(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
|
|
};
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(j)) if j.company_id == company.id => j,
|
|
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
|
|
};
|
|
|
|
let page = q.page.unwrap_or(1);
|
|
let limit = q.limit.unwrap_or(20);
|
|
let apps = match ApplicationRepository::list_by_job_id(&state.pool, job.id, q.status, page, limit).await {
|
|
Ok(a) => a,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
// Enrich each application with job seeker profile snapshot (no contact info)
|
|
let mut enriched = Vec::with_capacity(apps.len());
|
|
for app in apps {
|
|
use sqlx::Row;
|
|
let row = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
CONCAT(u.first_name, ' ', u.last_name) AS applicant_name,
|
|
u.avatar_url,
|
|
js.resume_url,
|
|
js.custom_data
|
|
FROM users u
|
|
LEFT JOIN job_seeker_profiles js ON js.user_id = u.id
|
|
WHERE u.id = $1
|
|
"#,
|
|
)
|
|
.bind(app.applicant_user_id)
|
|
.fetch_optional(&state.pool)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let applicant_name: String = row.as_ref()
|
|
.and_then(|r| r.try_get("applicant_name").ok())
|
|
.unwrap_or_default();
|
|
let avatar_url: Option<String> = row.as_ref()
|
|
.and_then(|r| r.try_get("avatar_url").ok());
|
|
let resume_url: Option<String> = row.as_ref()
|
|
.and_then(|r| r.try_get("resume_url").ok());
|
|
let custom_data: Option<serde_json::Value> = row.as_ref()
|
|
.and_then(|r| r.try_get("custom_data").ok());
|
|
|
|
// Extract portfolio fields — never expose email/phone
|
|
let portfolio = custom_data
|
|
.as_ref()
|
|
.and_then(|d| d.get("job_seeker_portfolio"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
|
|
let headline = portfolio.get("headline").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let skills = portfolio.get("skills").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let education = portfolio.get("education").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let work_experience = portfolio.get("workExperience").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let summary = portfolio.get("summary").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
|
|
enriched.push(serde_json::json!({
|
|
"id": app.id,
|
|
"job_id": app.job_id,
|
|
"applicant_user_id": app.applicant_user_id,
|
|
"status": app.status,
|
|
"cover_note": app.cover_note,
|
|
"applied_at": app.applied_at,
|
|
"updated_at": app.updated_at,
|
|
// Profile snapshot — contact fields intentionally omitted
|
|
"applicant_name": applicant_name,
|
|
"avatar_url": avatar_url,
|
|
"resume_url": resume_url,
|
|
"headline": headline,
|
|
"skills": skills,
|
|
"education": education,
|
|
"work_experience": work_experience,
|
|
"summary": summary,
|
|
}));
|
|
}
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"data": enriched,
|
|
"pagination": { "page": page, "limit": limit }
|
|
}))).into_response()
|
|
}
|
|
|
|
async fn update_application_status(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
Json(payload): Json<UpdateApplicationStatusPayload>,
|
|
) -> impl IntoResponse {
|
|
let app = match ApplicationRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(a)) => a,
|
|
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
|
|
};
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, app.job_id).await {
|
|
Ok(Some(j)) => j,
|
|
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
|
|
};
|
|
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
};
|
|
|
|
if job.company_id != company.id {
|
|
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
|
}
|
|
|
|
match ApplicationRepository::update_status(&state.pool, app.id, &payload.status).await {
|
|
Ok(updated) => {
|
|
// Notify applicant of status change (ignore failures)
|
|
let applicant_info = sqlx::query_as::<_, (String, String, Uuid)>(
|
|
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.id FROM users u WHERE u.id = $1",
|
|
)
|
|
.bind(app.applicant_user_id)
|
|
.fetch_optional(&state.pool)
|
|
.await;
|
|
if let Ok(Some((name, email, applicant_uuid))) = applicant_info {
|
|
let _ = state.mail.send_application_status_email(&email, &name, &job.title, &payload.status).await;
|
|
|
|
// Send in-app notification to job seeker
|
|
sqlx::query(
|
|
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
|
|
VALUES ($1, $2, $3, $4, $5)"#,
|
|
)
|
|
.bind(applicant_uuid)
|
|
.bind(format!("Application Status: {}", payload.status))
|
|
.bind(format!("Your application for '{}' has been {}.", job.title, payload.status.to_lowercase()))
|
|
.bind("APPLICATION")
|
|
.bind(app.id)
|
|
.execute(&state.pool)
|
|
.await
|
|
.ok();
|
|
}
|
|
(StatusCode::OK, Json(updated)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn upload_documents(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
mut multipart: Multipart,
|
|
) -> impl IntoResponse {
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => return (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
};
|
|
|
|
let mut uploaded_urls: Vec<String> = Vec::new();
|
|
|
|
while let Ok(Some(field)) = multipart.next_field().await {
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name != "documents" && name != "files" && name != "file" {
|
|
continue;
|
|
}
|
|
|
|
let content_type = field.content_type()
|
|
.unwrap_or("application/octet-stream")
|
|
.to_string();
|
|
|
|
let ext = if let Some(fname) = field.file_name() {
|
|
fname.rsplit('.').next().unwrap_or("bin").to_lowercase()
|
|
} else {
|
|
match content_type.as_str() {
|
|
"application/pdf" => "pdf".to_string(),
|
|
"image/jpeg" => "jpg".to_string(),
|
|
"image/png" => "png".to_string(),
|
|
_ => "bin".to_string(),
|
|
}
|
|
};
|
|
|
|
let data = match field.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(),
|
|
};
|
|
|
|
if data.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if data.len() > 10 * 1024 * 1024 {
|
|
return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB per file." }))).into_response();
|
|
}
|
|
|
|
let data_len = data.len();
|
|
let url = match state.storage
|
|
.upload("company_documents", &ext, data, &content_type)
|
|
.await
|
|
{
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
tracing::error!("B2 upload failed for company {}: {}", company.id, e);
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response();
|
|
}
|
|
};
|
|
|
|
// Persist document record
|
|
if let Err(e) = sqlx::query(
|
|
r#"
|
|
INSERT INTO company_documents (company_id, document_name, document_url, file_size, mime_type)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(company.id)
|
|
.bind(format!("document_{}", Uuid::new_v4()))
|
|
.bind(&url)
|
|
.bind(data_len as i64)
|
|
.bind(&content_type)
|
|
.execute(&state.pool)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to save document record for company {}: {}", company.id, e);
|
|
}
|
|
|
|
uploaded_urls.push(url);
|
|
}
|
|
|
|
if uploaded_urls.is_empty() {
|
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No valid document files provided. Send multipart fields named 'documents'." }))).into_response();
|
|
}
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"documents": uploaded_urls,
|
|
"count": uploaded_urls.len()
|
|
}))).into_response()
|
|
}
|
|
|
|
async fn view_contact(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
auth: AuthUser,
|
|
) -> impl IntoResponse {
|
|
let app = match ApplicationRepository::get_by_id(&state.pool, id).await {
|
|
Ok(Some(a)) => a,
|
|
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
|
|
};
|
|
|
|
let job = match JobRepository::get_by_id(&state.pool, app.job_id).await {
|
|
Ok(Some(j)) => j,
|
|
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
|
|
};
|
|
|
|
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
|
|
};
|
|
|
|
if job.company_id != company.id {
|
|
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
|
}
|
|
|
|
let free_views = company.free_contact_views;
|
|
let purchased_views = company.purchased_contact_views;
|
|
|
|
if free_views <= 0 && purchased_views <= 0 {
|
|
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
|
|
"error": "Contact view quota exhausted",
|
|
"code": "QUOTA_EXHAUSTED",
|
|
"requires_purchase": true,
|
|
"message": "You have used all your free contact views. Please purchase a contact view package to continue."
|
|
}))).into_response();
|
|
}
|
|
|
|
let used_free = free_views > 0;
|
|
|
|
if used_free {
|
|
sqlx::query(
|
|
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1"
|
|
)
|
|
.bind(company.id)
|
|
.execute(&state.pool)
|
|
.await
|
|
.ok();
|
|
} else {
|
|
sqlx::query(
|
|
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1"
|
|
)
|
|
.bind(company.id)
|
|
.execute(&state.pool)
|
|
.await
|
|
.ok();
|
|
}
|
|
|
|
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
|
|
r#"
|
|
SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone
|
|
FROM users u
|
|
WHERE u.id = $1
|
|
"#,
|
|
)
|
|
.bind(app.applicant_user_id)
|
|
.fetch_optional(&state.pool)
|
|
.await;
|
|
|
|
match contact {
|
|
Ok(Some((name, email, phone))) => {
|
|
let new_free = if used_free { free_views - 1 } else { free_views };
|
|
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
|
|
|
|
let _ = sqlx::query(
|
|
r#"
|
|
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#
|
|
)
|
|
.bind(app.applicant_user_id)
|
|
.bind("Your contact was viewed")
|
|
.bind(format!("{} viewed your application for {}", company.company_name, job.title))
|
|
.bind("APPLICATION")
|
|
.bind(id)
|
|
.execute(&state.pool)
|
|
.await
|
|
.ok();
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"application_id": id,
|
|
"name": name,
|
|
"email": email,
|
|
"phone": phone,
|
|
"quota": {
|
|
"used_free_view": used_free,
|
|
"free_remaining": new_free,
|
|
"purchased_remaining": new_purchased,
|
|
"total_remaining": new_free + new_purchased
|
|
}
|
|
}))).into_response()
|
|
}
|
|
Ok(None) => (StatusCode::NOT_FOUND, "Applicant not found").into_response(),
|
|
Err(e) => {
|
|
tracing::error!("Failed to fetch applicant contact: {}", e);
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response()
|
|
}
|
|
}
|
|
}
|