pub mod admin; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, patch, post}, Json, Router, }; 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 contracts::auth_middleware::AuthUser; use crate::AppState; pub fn router() -> Router { Router::new() .route("/profile/me", get(get_profile).patch(update_profile)) .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, pub limit: Option, pub status: Option, } #[derive(Deserialize)] pub struct CreateJobRequest { pub title: String, pub description: String, pub location: String, pub job_type: Option, pub salary_min: Option, pub salary_max: Option, pub experience_years: Option, pub skills: Option>, pub category: Option, } #[derive(Deserialize)] pub struct UpdateApplicationStatusPayload { pub status: String, } async fn get_profile( State(state): State, auth: AuthUser, ) -> impl IntoResponse { match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(Some(profile)) => (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, auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { match CompanyRepository::upsert(&state.pool, auth.user_id, payload).await { Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn list_jobs( State(state): State, auth: AuthUser, Query(q): Query, ) -> 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); match JobRepository::list_by_company_id(&state.pool, company.id, q.status, page, limit).await { Ok(jobs) => (StatusCode::OK, Json(serde_json::json!({ "data": jobs, "pagination": { "page": page, "limit": limit } }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn create_job( State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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", 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) => (StatusCode::CREATED, Json(job)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn get_job( State(state): State, Path(id): Path, _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, Path(id): Path, auth: AuthUser, Json(payload): Json, ) -> 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) => (StatusCode::OK, Json(updated)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn submit_job( State(state): State, Path(id): Path, 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, user.full_name.as_deref().unwrap_or("User"), &updated.title).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, Path(id): Path, 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) => (StatusCode::OK, Json(updated)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn list_applications( State(state): State, Path(id): Path, auth: AuthUser, Query(q): Query, ) -> 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); match ApplicationRepository::list_by_job_id(&state.pool, job.id, q.status, page, limit).await { Ok(apps) => (StatusCode::OK, Json(serde_json::json!({ "data": apps, "pagination": { "page": page, "limit": limit } }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn update_application_status( State(state): State, Path(id): Path, auth: AuthUser, Json(payload): Json, ) -> 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)>( "SELECT u.full_name, u.email FROM users u INNER JOIN job_seekers js ON js.user_id = u.id WHERE js.id = $1", ) .bind(app.job_seeker_id) .fetch_optional(&state.pool) .await; if let Ok(Some((name, email))) = applicant_info { let _ = state.mail.send_application_status_email(&email, &name, &job.title, &payload.status).await; } (StatusCode::OK, Json(updated)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } async fn view_contact( State(state): State, Path(id): Path, 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(); } // If contact was already viewed for this application, return info without deducting again if !app.contact_viewed { let total_remaining = company.free_contact_views + company.purchased_contact_views; if total_remaining <= 0 { return ( StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ "error": "Contact view quota exhausted. Please purchase a package.", "code": "QUOTA_EXHAUSTED" })), ) .into_response(); } // Deduct from free views first, then purchased let sql = if company.free_contact_views > 0 { "UPDATE companies SET free_contact_views = free_contact_views - 1 WHERE id = $1" } else { "UPDATE companies SET purchased_contact_views = purchased_contact_views - 1 WHERE id = $1" }; if let Err(e) = sqlx::query(sql).bind(company.id).execute(&state.pool).await { tracing::error!("Failed to deduct contact view quota: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to deduct quota").into_response(); } if let Err(e) = ApplicationRepository::mark_contact_viewed(&state.pool, app.id).await { tracing::error!("Failed to mark contact viewed: {}", e); } } // Fetch job seeker contact info via job_seeker_id → job_seekers.user_id → users let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" SELECT u.full_name, u.email, u.phone FROM users u INNER JOIN job_seekers js ON js.user_id = u.id WHERE js.id = $1 "#, ) .bind(app.job_seeker_id) .fetch_optional(&state.pool) .await; match contact { Ok(Some((full_name, email, phone))) => { // Fetch updated quota to return to client let updated_company = CompanyRepository::get_by_user_id(&state.pool, auth.user_id) .await .ok() .flatten(); let (free_remaining, purchased_remaining) = updated_company .map(|c| (c.free_contact_views, c.purchased_contact_views)) .unwrap_or((0, 0)); (StatusCode::OK, Json(serde_json::json!({ "application_id": id, "full_name": full_name, "email": email, "phone": phone, "quota": { "free_remaining": free_remaining, "purchased_remaining": purchased_remaining, "total_remaining": free_remaining + purchased_remaining } }))) .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() } } }