From a4af5c2c068f2d5e7b506e200f290211724cdfa6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 14 Jun 2026 18:00:38 +0200 Subject: [PATCH 1/6] feat: add LiteLLM support and auto-apply cron job - Add get_llm_base_url() and get_llm_model() helper functions - Update call_ollama_inline to route to LiteLLM when LLM_PROVIDER=litellm - Add auto-apply cron task for background job matching - Auto-apply matches job seekers to new jobs based on skills - Generate cover letters via LiteLLM for each application --- .mavis/plans/e2e-full-test.yaml | 328 ++++++++++++++++++++++++++++++ Cargo.lock | 1 + apps/cron/Cargo.toml | 1 + apps/cron/src/main.rs | 13 ++ apps/cron/src/tasks/auto_apply.rs | 318 +++++++++++++++++++++++++++++ apps/cron/src/tasks/mod.rs | 1 + apps/users/src/handlers/ai.rs | 158 ++++++++++---- 7 files changed, 780 insertions(+), 40 deletions(-) create mode 100644 .mavis/plans/e2e-full-test.yaml create mode 100644 apps/cron/src/tasks/auto_apply.rs diff --git a/.mavis/plans/e2e-full-test.yaml b/.mavis/plans/e2e-full-test.yaml new file mode 100644 index 0000000..67ba858 --- /dev/null +++ b/.mavis/plans/e2e-full-test.yaml @@ -0,0 +1,328 @@ +version: 1 +plan: + name: nxtgauge-full-e2e-test + max_concurrency: 6 + max_consecutive_failures: 3 + max_cycles: 10 + auto_reject_retries: 1 +tasks: + - id: track-1-professions + title: Test all 10 profession services + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + BASE_URL: http://localhost:9100 + GATEWAY is at :9100 (routes to all services) + Individual service ports: + photographers: 9107, makeup_artists: 9109, tutors: 9108, developers: 9110, + video_editors: 9111, graphic_designers: 9112, social_media_managers: 9113, + fitness_trainers: 9114, catering_services: 9115, ugc_content_creators: 9117 + Test accounts (use test_mode=true to bypass OTP): + Professional: bug2_test_1781086381@test.com / Test1234! (PHOTOGRAPHER role active) + Customer: cu_1781041378@test.com / Test1234! (CUSTOMER role active) + Open lead ID: ba18c522-b3cd-43a7-a269-90cf981c173e (PHOTOGRAPHER, status OPEN) + + FOR EACH of the 10 profession services below, test: + 1. Health check: GET http://localhost:/health or via gateway + 2. Login to get a fresh token (use test_mode=true register, then login) + 3. GET /api//profile/me — does profile endpoint work? + 4. GET /api//marketplace — does marketplace browse work? + 5. Send a lead request via POST /api//leads/request with body {"requirement_id":"ba18c522-b3cd-43a7-a269-90cf981c173e","message":"interested"} + + Professions to test: + - photographers (9107) + - makeup_artists (9109) — route is /api/makeup-artists + - tutors (9108) + - developers (9110) — route is /api/developers + - video_editors (9111) — route is /api/video-editors + - graphic_designers (9112) — route is /api/graphic-designers + - social_media_managers (9113) — route is /api/social-media-managers + - fitness_trainers (9114) — route is /api/fitness-trainers + - catering_services (9115) — route is /api/catering-services + - ugc_content_creators (9117) — route is /api/ugc-content-creators + + NOTE: The "professional" user (bug2_test_1781086381@test.com) only has PHOTOGRAPHER role. For other professions, register a fresh user, register the role, switch to that role, then test. + + Write results to /Users/ashwin/workspace/e2e-results/track-1-professions.md with: + - For each profession: service port, health status, profile result, marketplace result, lead_request result, any errors + - Summary of which services are fully functional vs partially working vs broken + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-2-leads + title: Test leads service and lead request accept/reject flow + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + Gateway: http://localhost:9100 + Leads service: http://localhost:9118 (or via gateway /api/leads) + Customers service: http://localhost:9105 + + Test accounts (use test_mode=true): + Customer: cu_1781041378@test.com / Test1234! + Photographer: bug2_test_1781086381@test.com / Test1234! (has PHOTOGRAPHER approved profile) + + Test the following flows: + + 1. LEADS SERVICE CRUD: + - GET /api/leads — list all leads (or http://localhost:9118/leads) + - POST /api/leads — create a new lead {"title":"Test Lead 2","description":"API test","location":"Delhi","profession_key":"PHOTOGRAPHER"} + - GET /api/leads/:id — get specific lead + - PATCH /api/leads/:id — update the lead title + + 2. CUSTOMER VIEW REQUESTS (the /requests endpoint we fixed): + - Login as customer, get token + - GET /api/customers/requests — should return HTTP 200 with {"data":[]} + - POST a lead request as the photographer, then check GET /api/customers/requests again + + 3. CUSTOMER ACCEPT/REJECT LEAD REQUEST: + - First, check what the approve/reject endpoints look like + - Try POST /api/customers/requests/:lead_request_id/approve + - Try POST /api/customers/requests/:lead_request_id/reject + - Note: these may fail with 404 or 500 — document what happens + + 4. PROFESSIONAL LEAD REQUEST FLOW: + - Login as photographer, get fresh token + - GET /api/photographers/marketplace — should show OPEN leads + - POST /api/photographers/leads/request — send a new lead request + - GET /api/photographers/leads/requests/me — list my sent requests + - DELETE /api/photographers/leads/requests/:id — cancel a pending request + + Write results to /Users/ashwin/workspace/e2e-results/track-2-leads.md with: + - Each endpoint tested, HTTP status, response body + - Any errors, 4xx/5xx codes, and what they mean + - Overall status of leads + lead request flow + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-3-payments + title: Test payments and tracecoin flows + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + Gateway: http://localhost:9100 + Payments service: http://localhost:9116 + Test accounts: bug2_test_1781086381@test.com / Test1234! + + DATABASE: postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + + Test these flows: + + 1. TRACEOCOIN WALLET: + - GET /api/packages — list all pricing packages (via gateway or users service) + - GET /api/photographers/wallet/me — check photographer wallet (may fail with 404 if not set up) + - Check DB: psql "postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db" -c "SELECT * FROM tracecoin_wallets LIMIT 5;" + + 2. TRACEOCOIN PURCHASE (if payments service is running on 9116): + - GET http://localhost:9116/health — is payments service up? + - GET http://localhost:9116/api/packages — list packages + - Try to initiate a tracecoin purchase (check the payments service routes) + + 3. PAYMENTS SERVICE ROUTES: + - Document what routes the payments service exposes + - Try GET /api/payments/health + - Try GET /api/payments/packages + - Check what the Beeceptor mock URL is (if used) + + 4. TRACEOCOIN LEDGER: + - Check DB: psql "postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db" -c "SELECT * FROM tracecoin_ledger LIMIT 10;" + - Check the schema of tracecoin_ledger table + + 5. INVOICES: + - Check DB: psql ... -c "SELECT * FROM invoices LIMIT 5;" + - Check invoices table schema + + Write results to /Users/ashwin/workspace/e2e-results/track-3-payments.md with: + - Payments service status (running or not) + - All endpoints tested with HTTP status and response + - Tracecoin wallet balances found in DB + - Any payment flow errors + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-4-admin + title: Test admin approval and verification workflow + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + Gateway: http://localhost:9100 + Test admin account: cu_1781041378@test.com / Test1234! (or use admin credentials if available) + + NOTE: The admin dashboard is a separate Next.js app. Test the backend API endpoints only. + + Test these flows: + + 1. ADMIN VERIFICATIONS ENDPOINT: + - GET /api/admin/verifications — list verification cases (requires admin auth) + - GET /api/admin/verifications?status=PENDING — filter by status + - What does this return? 200? 403? 401? + + 2. ADMIN APPROVALS ENDPOINT: + - GET /api/admin/approvals — list approval cases + - What does it return? + + 3. APPROVE A JOB (job posting approval): + - First create a job as a company (use co_1781041378@test.com / Test1234! if available, or register new) + - Submit the job (status goes to PENDING_APPROVAL) + - Try to approve it via admin endpoint: POST /api/admin/jobs/:job_id/approve or similar + - Document what admin job approval endpoints exist + + 4. APPROVE A REQUIREMENT (customer lead approval): + - Create a customer requirement + - Submit it (status goes to PENDING_APPROVAL) + - Try to approve via admin endpoint + + 5. ADMIN APPROVAL FLOW: + - Check what routes exist under /api/admin/ for approvals + - GET /api/admin/approval-cases if it exists + - Try to find and call the actual approval endpoints + + 6. VERIFICATION MANAGEMENT: + - GET /api/admin/verifications — list all verifications + - Try to approve/reject a verification + + Write results to /Users/ashwin/workspace/e2e-results/track-4-admin.md with: + - Each admin endpoint tested, HTTP status, response + - Whether verification/approval workflow is functional end-to-end + - Missing or broken admin endpoints + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-5-support + title: Test support tickets and email notifications + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + Gateway: http://localhost:9100 + Test accounts: bug2_test_1781086381@test.com / Test1234! (or register fresh users with test_mode=true) + + Test these flows: + + 1. SUPPORT TICKETS (user-facing): + - POST /api/support/tickets — create a support ticket + Body: {"subject":"Test ticket from API","message":"This is a test","category":"GENERAL"} + - GET /api/support/tickets — list my tickets + - GET /api/support/tickets/:id — get specific ticket + - POST /api/support/tickets/:id/messages — add a message to a ticket + + 2. SUPPORT TICKETS (admin): + - GET /api/admin/support-cases — list all support cases (admin only) + - Try to reply to a ticket as admin + + 3. EMAIL NOTIFICATIONS (check if emails are being sent): + - Create a ticket → check DB: psql "postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db" -c "SELECT * FROM email_logs ORDER BY created_at DESC LIMIT 10;" + - Submit a job → check email_logs + - Submit a requirement → check email_logs + - Submit a job application → check email_logs + + 4. NOTIFICATIONS: + - GET /api/me/notifications — list user notifications + - Check notification table: psql "postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db" -c "SELECT * FROM notifications ORDER BY created_at DESC LIMIT 20;" + + 5. KNOWLEDGE BASE: + - GET /api/kb/articles — list KB articles + - GET /api/kb/articles/:id — get specific article + - GET /api/kb/categories — list categories + + Write results to /Users/ashwin/workspace/e2e-results/track-5-support.md with: + - Support ticket creation status (success/fail/error) + - Email logs found in DB (were emails sent?) + - Notifications generated + - KB articles accessible + - Any broken endpoints + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-6-reviews-portfolio + title: Test reviews, portfolio, and onboarding flows + prompt: | + You are testing the Nxtgauge backend API. All tests are via direct HTTP API calls. + + Gateway: http://localhost:9100 + Test accounts: register fresh users with test_mode=true as needed + + DATABASE: postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + + Test these flows: + + 1. REVIEWS: + - POST /api/reviews — submit a review + Body: {"entity_type":"COMPANY","entity_id":"","rating":5,"comment":"Great service!"} + (You'll need to find a valid company_id from the DB: psql ... -c "SELECT id FROM company_profiles LIMIT 3;") + - GET /api/reviews — list reviews + - GET /api/reviews?entity_type=COMPANY&entity_id= — reviews for a company + - Admin: GET /api/admin/reviews — admin review list + + 2. PORTFOLIO (photographer): + - Login as photographer (bug2_test_1781086381@test.com / Test1234!) + - POST /api/photographers/portfolio/me — create portfolio item + Body: {"title":"Test Portfolio","description":"Test description","image_url":"https://example.com/test.jpg"} + - GET /api/photographers/portfolio/me — list portfolio items + - PATCH /api/photographers/portfolio/me/:id — update portfolio item + - DELETE /api/photographers/portfolio/me/:id — delete portfolio item + + 3. SERVICES (photographer): + - POST /api/photographers/services/me — create a service + Body: {"name":"Wedding Photography Package","description":"Full day coverage","price_inr":50000} + - GET /api/photographers/services/me — list services + - PATCH /api/photographers/services/me/:id — update service + - DELETE /api/photographers/services/me/:id — delete service + + 4. ONBOARDING STATE: + - GET /api/onboarding/state — get onboarding state for a user + - POST /api/onboarding/submit — submit onboarding data + - GET /api/me — check if role and profile status is shown + + 5. RUNTIME CONFIG: + - GET /api/runtime-config — get runtime config for a role + - GET /api/runtime-config?role=PHOTOGRAPHER — get specific role config + + Write results to /Users/ashwin/workspace/e2e-results/track-6-reviews-portfolio.md with: + - Each endpoint tested, HTTP status, response + - DB schema for reviews and portfolio tables + - Any errors found + assigned_to: general + verify_skip_reason: "Downstream compilation task reads all result files directly; no independent verification needed for raw API test output." + max_retries: 1 + + - id: track-7-compile-report + title: Compile comprehensive test report + role: verify-as-task + prompt: | + Read all the test result files created by the other tracks and compile a comprehensive report. + + Read these files: + - /Users/ashwin/workspace/e2e-results/track-1-professions.md + - /Users/ashwin/workspace/e2e-results/track-2-leads.md + - /Users/ashwin/workspace/e2e-results/track-3-payments.md + - /Users/ashwin/workspace/e2e-results/track-4-admin.md + - /Users/ashwin/workspace/e2e-results/track-5-support.md + - /Users/ashwin/workspace/e2e-results/track-6-reviews-portfolio.md + - Also read: /Users/ashwin/workspace/E2E_TEST_REPORT.md (the previous report from Session 1) + + Compile everything into a single comprehensive report at: + /Users/ashwin/workspace/e2e-results/COMPLETE_E2E_REPORT.md + + The report should include: + 1. Executive summary: overall system health (X/Y endpoints working) + 2. Track-by-track results with PASS/FAIL for each endpoint tested + 3. All bugs found this round (new issues not in the previous report) + 4. All bugs from the previous report and their current status (fixed/still broken) + 5. Recommendations and priority list of what to fix next + 6. Test data created during this round + + Format: Markdown report. Be thorough — this is the canonical test record. + assigned_to: general + depends_on: + - track-1-professions + - track-2-leads + - track-3-payments + - track-4-admin + - track-5-support + - track-6-reviews-portfolio + max_retries: 1 diff --git a/Cargo.lock b/Cargo.lock index e5a4483..85ad42b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -941,6 +941,7 @@ dependencies = [ "chrono", "db", "email", + "reqwest", "serde", "serde_json", "sqlx", diff --git a/apps/cron/Cargo.toml b/apps/cron/Cargo.toml index 0a992dc..21220af 100644 --- a/apps/cron/Cargo.toml +++ b/apps/cron/Cargo.toml @@ -18,3 +18,4 @@ serde.workspace = true serde_json.workspace = true chrono.workspace = true uuid.workspace = true +reqwest.workspace = true diff --git a/apps/cron/src/main.rs b/apps/cron/src/main.rs index 752bd0a..a9c261c 100644 --- a/apps/cron/src/main.rs +++ b/apps/cron/src/main.rs @@ -75,6 +75,19 @@ async fn main() -> Result<(), Box> { } }); + // Spawn Hourly Auto-Apply task for job seekers + let p_auto_apply_sys = pool.clone(); + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(60 * 60)); + loop { + interval.tick().await; + tracing::info!("Running Auto-Apply Task..."); + if let Err(e) = tasks::auto_apply::run_auto_apply(&p_auto_apply_sys).await { + tracing::error!("Auto-Apply Task Failed: {}", e); + } + } + }); + // Keep main thread alive tokio::signal::ctrl_c().await?; tracing::info!("Shutting down cron engine."); diff --git a/apps/cron/src/tasks/auto_apply.rs b/apps/cron/src/tasks/auto_apply.rs new file mode 100644 index 0000000..9fc44d4 --- /dev/null +++ b/apps/cron/src/tasks/auto_apply.rs @@ -0,0 +1,318 @@ +use chrono::{Duration, Utc}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct AutoApplyConfig { + litellm_base_url: String, + litellm_api_key: String, + litellm_model: String, + max_applications_per_run: usize, + max_daily_applications: usize, +} + +impl AutoApplyConfig { + fn from_env() -> Self { + Self { + litellm_base_url: std::env::var("LITELLM_BASE_URL") + .unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()), + litellm_api_key: std::env::var("LITELLM_API_KEY").unwrap_or_default(), + litellm_model: std::env::var("LITELLM_MODEL") + .unwrap_or_else(|_| "askash-main".to_string()), + max_applications_per_run: std::env::var("AUTO_APPLY_MAX_PER_RUN") + .unwrap_or_else(|_| "5".to_string()) + .parse() + .unwrap_or(5), + max_daily_applications: std::env::var("AUTO_APPLY_MAX_DAILY") + .unwrap_or_else(|_| "20".to_string()) + .parse() + .unwrap_or(20), + } + } +} + +#[derive(Debug, Serialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, + temperature: f32, + max_tokens: i32, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: Message, +} + +#[derive(Debug, Deserialize)] +struct Message { + content: String, +} + +async fn generate_cover_letter( + client: &Client, + config: &AutoApplyConfig, + seeker_name: &str, + experience: i32, + skills: &[String], + summary: Option<&str>, + job_title: &str, + job_desc: &str, +) -> Result> { + let url = format!("{}/chat/completions", config.litellm_base_url.trim_end_matches('/')); + + let prompt = format!( + "Write a brief, professional cover letter (max 200 words).\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information.\n\ + Only use the information provided below.\n\n\ + CANDIDATE: Name: {}, Experience: {} years, Skills: {}, Summary: {}\n\ + JOB: Title: {}, Description: {}\n\n\ + Cover Letter:", + seeker_name, + experience, + skills.join(", "), + summary.unwrap_or(""), + job_title, + job_desc + ); + + let payload = ChatCompletionRequest { + model: config.litellm_model.clone(), + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: "You are a professional cover letter writer.".to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: prompt, + }, + ], + temperature: 0.2, + max_tokens: 500, + }; + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.litellm_api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await?; + + if !res.status().is_success() { + return Ok("I am excited to apply for this position.".to_string()); + } + + let body: ChatCompletionResponse = res.json().await?; + + if let Some(choice) = body.choices.first() { + Ok(choice.message.content.trim().to_string()) + } else { + Ok("I am excited to apply for this position.".to_string()) + } +} + +#[derive(Debug, sqlx::FromRow)] +struct JobSeekerWithAi { + user_id: Uuid, + profile_id: Uuid, + full_name: String, + skills: Vec, + experience_years: i32, + summary: Option, + daily_limit: i32, + used_today: i32, +} + +#[derive(Debug, sqlx::FromRow)] +struct NewJob { + id: Uuid, + title: String, + description: String, + skills: Vec, +} + +pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box> { + let config = AutoApplyConfig::from_env(); + + if config.litellm_api_key.is_empty() { + tracing::warn!("Auto-apply skipped: LITELLM_API_KEY not configured"); + return Ok(()); + } + + tracing::info!("Starting auto-apply job..."); + + let client = Client::new(); + let cutoff_time = Utc::now() - Duration::hours(24); + + let seekers: Vec = sqlx::query_as( + r#" + SELECT + u.id as user_id, + jsp.id as profile_id, + COALESCE(CONCAT(jsp.first_name, ' ', jsp.last_name), 'Candidate') as full_name, + COALESCE(jsp.skills, ARRAY[]::text[]) as skills, + COALESCE(jsp.experience_years, 0) as experience_years, + jsp.summary, + COALESCE(( + SELECT p.monthly_action_limit / 30 + FROM ai_entitlements e + JOIN ai_plans p ON p.id = e.plan_id + WHERE e.user_id = u.id AND e.role = 'JOB_SEEKER' AND e.status = 'active' + ORDER BY e.valid_from DESC + LIMIT 1 + ), 10) as daily_limit, + COALESCE(( + SELECT generations_used + FROM job_seeker_ai_usage jau + WHERE jau.job_seeker_id = jsp.id + AND usage_date = CURRENT_DATE + ), 0) as used_today + FROM users u + INNER JOIN job_seeker_profiles jsp ON jsp.user_id = u.id + WHERE u.status = 'ACTIVE' + AND jsp.status IN ('APPROVED', 'PENDING_REVIEW') + AND jsp.skills IS NOT NULL + AND array_length(jsp.skills, 1) > 0 + AND u.email_verified = true + "# + ) + .fetch_all(pool) + .await?; + + if seekers.is_empty() { + tracing::info!("No job seekers with AI packs found for auto-apply"); + return Ok(()); + } + + tracing::info!("Found {} job seekers eligible for auto-apply", seekers.len()); + + let mut total_applications = 0; + + for seeker in seekers { + let remaining_today = seeker.daily_limit - seeker.used_today; + if remaining_today <= 0 { + tracing::debug!("User {} has no remaining AI applications today", seeker.user_id); + continue; + } + + let new_jobs: Vec = sqlx::query_as( + r#" + SELECT j.id, j.title, j.description, COALESCE(j.skills, ARRAY[]::text[]) as skills + FROM jobs j + INNER JOIN companies c ON c.id = j.company_id + WHERE j.status = 'LIVE' + AND j.created_at > $1 + AND c.status = 'APPROVED' + AND j.id NOT IN ( + SELECT job_id FROM job_applications WHERE applicant_user_id = $2 + ) + AND j.skills && $3::text[] + ORDER BY j.created_at DESC + LIMIT $4 + "# + ) + .bind(cutoff_time) + .bind(seeker.user_id) + .bind(&seeker.skills) + .bind(std::cmp::min(remaining_today as i32, config.max_applications_per_run as i32)) + .fetch_all(pool) + .await?; + + if new_jobs.is_empty() { + continue; + } + + tracing::info!( + "User {} has {} matching new jobs, applying to {}", + seeker.user_id, + new_jobs.len(), + new_jobs.len().min(remaining_today as usize) + ); + + for job in new_jobs.into_iter().take(remaining_today as usize) { + let cover_letter = match generate_cover_letter( + &client, + &config, + &seeker.full_name, + seeker.experience_years, + &seeker.skills, + seeker.summary.as_deref(), + &job.title, + &job.description, + ) + .await + { + Ok(cl) => cl, + Err(e) => { + tracing::warn!("Failed to generate cover letter: {}", e); + "I am excited to apply for this position.".to_string() + } + }; + + let result = sqlx::query( + r#" + INSERT INTO job_applications (job_id, applicant_user_id, cover_letter, applied_via_ai) + VALUES ($1, $2, $3, true) + ON CONFLICT (job_id, applicant_user_id) DO NOTHING + "# + ) + .bind(job.id) + .bind(seeker.user_id) + .bind(&cover_letter) + .execute(pool) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + total_applications += 1; + + sqlx::query( + r#" + INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used) + VALUES ($1, CURRENT_DATE, 1) + ON CONFLICT (job_seeker_id, usage_date) + DO UPDATE SET generations_used = job_seeker_ai_usage.generations_used + 1 + "# + ) + .bind(seeker.profile_id) + .execute(pool) + .await + .ok(); + + sqlx::query( + "UPDATE job_seeker_profiles SET active_application_count = active_application_count + 1 WHERE id = $1" + ) + .bind(seeker.profile_id) + .execute(pool) + .await + .ok(); + + tracing::info!("Auto-applied user {} to job {}", seeker.user_id, job.id); + } + } + Err(e) => { + tracing::error!("Failed to insert application: {}", e); + } + } + } + } + + tracing::info!("Auto-apply completed. Total applications: {}", total_applications); + Ok(()) +} diff --git a/apps/cron/src/tasks/mod.rs b/apps/cron/src/tasks/mod.rs index a48bd41..426b42c 100644 --- a/apps/cron/src/tasks/mod.rs +++ b/apps/cron/src/tasks/mod.rs @@ -2,3 +2,4 @@ pub mod leads; pub mod requirements; pub mod jobs; pub mod reminders; +pub mod auto_apply; diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 7e859b4..681f48f 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -51,32 +51,7 @@ struct OllamaGenerateResponse { async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result { let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let url = format!("{}/api/generate", base_url); - - let req = OllamaGenerateRequest { - model: model.to_string(), - prompt: prompt.to_string(), - stream: false, - }; - - let client = reqwest::Client::new(); - let response = client - .post(&url) - .json(&req) - .send() - .await - .map_err(|e| format!("ollama request failed: {}", e))?; - - if !response.status().is_success() { - return Err(format!("ollama returned status: {}", response.status())); - } - - let result: OllamaGenerateResponse = response - .json() - .await - .map_err(|e| format!("failed to parse ollama response: {}", e))?; - - Ok(result.response) + call_ollama_inline(&base_url, model, prompt).await } // ── Phase 1: Strict keyword fast-path for intent classification ──────────────── @@ -318,7 +293,39 @@ fn is_internal_admin(auth: &AuthUser) -> bool { || auth.claims.roles.contains(&"SUPER_ADMIN".to_string()) } -async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result { +fn get_llm_base_url() -> String { + let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string()); + if llm_provider == "litellm" { + std::env::var("LITELLM_BASE_URL") + .unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()) + } else { + std::env::var("OLLAMA_BASE_URL") + .unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()) + } +} + +fn get_llm_model() -> String { + let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string()); + if llm_provider == "litellm" { + std::env::var("LITELLM_MODEL") + .unwrap_or_else(|_| "askash-main".to_string()) + } else { + std::env::var("OLLAMA_CHAT_MODEL") + .unwrap_or_else(|_| "gemma3:270m".to_string()) + } +} + +async fn call_ollama_inline(_base_url: &str, _model: &str, prompt: &str) -> Result { + let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string()); + + if llm_provider == "litellm" { + let base_url = get_llm_base_url(); + let model = get_llm_model(); + return call_litellm_inline(&base_url, &model, prompt).await; + } + + let base_url = get_llm_base_url(); + let model = get_llm_model(); let url = format!("{}/api/generate", base_url); let req = OllamaGenerateRequest { model: model.to_string(), @@ -346,6 +353,77 @@ async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result Ok(result.response) } +#[derive(Debug, Serialize)] +struct LiteLLMChatMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct LiteLLMRequest { + model: String, + messages: Vec, + temperature: f32, + max_tokens: i32, +} + +#[derive(Debug, Deserialize)] +struct LiteLLMResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct LiteLLMChoice { + message: LiteLLMMessage, +} + +#[derive(Debug, Deserialize)] +struct LiteLLMMessage { + content: String, +} + +async fn call_litellm_inline(base_url: &str, model: &str, prompt: &str) -> Result { + let api_key = std::env::var("LITELLM_API_KEY").unwrap_or_default(); + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + + let payload = LiteLLMRequest { + model: model.to_string(), + messages: vec![ + LiteLLMChatMessage { + role: "user".to_string(), + content: prompt.to_string(), + }, + ], + temperature: 0.2, + max_tokens: 2048, + }; + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| format!("litellm request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("litellm returned status: {}", response.status())); + } + + let result: LiteLLMResponse = response + .json() + .await + .map_err(|e| format!("failed to parse litellm response: {}", e))?; + + if let Some(choice) = result.choices.first() { + Ok(choice.message.content.trim().to_string()) + } else { + Err("litellm returned empty response".to_string()) + } +} + async fn ai_chat_message( State(state): State, Json(body): Json, @@ -355,13 +433,13 @@ async fn ai_chat_message( return (status, Json(payload)).into_response(); } - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let default_conversation = Uuid::new_v4().to_string(); let conversation_id = body.conversation_id.unwrap_or_else(|| default_conversation); - // ── Phase 1: Strict keyword fast-path (skips Ollama when unambiguous) ───── + // ── Phase 1: Strict keyword fast-path (skips LLM when unambiguous) ───── let (intent, confidence) = match classify_strict_keywords(&body.message) { Some((kw_intent, kw_conf)) => (kw_intent.to_string(), kw_conf), None => classify_intent(&body.message, &ollama_base, &model).await, @@ -606,8 +684,8 @@ async fn ai_extract_form( State(_state): State, Json(body): Json, ) -> impl IntoResponse { - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let form_type = body.form_type.unwrap_or_else(|| "generic".to_string()); @@ -834,8 +912,8 @@ async fn ai_generate_job_field( } }; - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let field_prompt = match body.field.as_str() { "title" => format!( @@ -949,8 +1027,8 @@ async fn ai_generate_cover_letter( } }; - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let notes = body.additional_notes.as_deref().unwrap_or(""); let skills_str = skills.join(", "); @@ -1062,8 +1140,8 @@ async fn ai_tailor_resume( } }; - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); let skills_str = skills.join(", "); @@ -1186,8 +1264,8 @@ async fn ai_auto_apply( return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); } - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let ollama_base = get_llm_base_url(); + let model = get_llm_model(); let skills_str = skills.join(", "); let mut created = 0; From 8188072b082f277fa4c1c3a7acbeecce0509908a Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 22:49:06 +0530 Subject: [PATCH 2/6] ci: deploy backend via github actions and ghcr --- .github/workflows/build-and-deploy-ghcr.yml | 172 ++++++++++++++++++++ .github/workflows/sync-to-forgejo.yml | 40 ----- Dockerfile.simple | 2 +- 3 files changed, 173 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/build-and-deploy-ghcr.yml delete mode 100644 .github/workflows/sync-to-forgejo.yml diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml new file mode 100644 index 0000000..4e3e7c3 --- /dev/null +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -0,0 +1,172 @@ +name: build-and-deploy-ghcr + +on: + push: + branches: + - main + - high-performance + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + K8S_NAMESPACE: nxtgauge + GITOPS_REPO: Traceworks2023/nxtgauge-gitops + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + services: ${{ steps.detect.outputs.services }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect changed services + id: detect + shell: bash + run: | + set -euo pipefail + ALL_SERVICES='gateway users companies jobs leads job-seekers customers payments employees photographers makeup-artists tutors developers video-editors graphic-designers social-media-managers fitness-trainers catering-services ugc-content-creators cron' + + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + CHANGED_FILES="$(git diff --name-only HEAD^ HEAD)" + else + CHANGED_FILES="$(git ls-files)" + fi + + LAST_COMMIT_MSG="$(git log -1 --pretty=%B | tr '\n' ' ')" + FORCE_FULL_BUILD=false + if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger build|force build|rebuild all'; then + FORCE_FULL_BUILD=true + elif echo "$CHANGED_FILES" | grep -Eq '^(\.github/workflows/|\.forgejo/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then + FORCE_FULL_BUILD=true + fi + + if [ "$FORCE_FULL_BUILD" = true ]; then + SERVICES_JSON="$(printf '%s\n' $ALL_SERVICES | python3 -c 'import json,sys; print(json.dumps([line.strip() for line in sys.stdin if line.strip()]))')" + echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT" + exit 0 + fi + + SERVICES_JSON="$(printf '%s\n' "$CHANGED_FILES" | python3 -c ' +import json +import sys +mapping = { + "apps/gateway/": "gateway", + "apps/users/": "users", + "apps/companies/": "companies", + "apps/jobs/": "jobs", + "apps/leads/": "leads", + "apps/job_seekers/": "job-seekers", + "apps/customers/": "customers", + "apps/payments/": "payments", + "apps/employees/": "employees", + "apps/photographers/": "photographers", + "apps/makeup_artists/": "makeup-artists", + "apps/tutors/": "tutors", + "apps/developers/": "developers", + "apps/video_editors/": "video-editors", + "apps/graphic_designers/": "graphic-designers", + "apps/social_media_managers/": "social-media-managers", + "apps/fitness_trainers/": "fitness-trainers", + "apps/catering_services/": "catering-services", + "apps/ugc_content_creators/": "ugc-content-creators", + "apps/cron/": "cron", +} +services = [] +seen = set() +for raw in sys.stdin: + line = raw.strip() + for prefix, service in mapping.items(): + if line.startswith(prefix) and service not in seen: + seen.add(service) + services.append(service) + break +print(json.dumps(services)) +')" + echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT" + + build-deploy: + needs: detect-changes + if: needs.detect-changes.outputs.services != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 + matrix: + service: ${{ fromJson(needs.detect-changes.outputs.services) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + + - name: Build and push image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.simple + push: true + platforms: linux/amd64 + build-args: | + SERVICE_NAME=${{ matrix.service }} + tags: ghcr.io/${{ github.repository_owner }}/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} + + - name: Configure kubeconfig + run: | + set -euo pipefail + mkdir -p ~/.kube + printf '%s' '${{ secrets.KUBE_CONFIG_DATA }}' | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Install kubectl + uses: azure/setup-kubectl@v4 + + - name: Deploy to Kubernetes + env: + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + run: | + set -euo pipefail + deployment="nxtgauge-rust-${{ matrix.service }}" + container="${{ matrix.service }}" + image_ref="ghcr.io/${{ github.repository_owner }}/nxtgauge-rust-${{ matrix.service }}@${{ steps.build.outputs.digest }}" + kubectl -n "$K8S_NAMESPACE" create secret docker-registry ghcr-regcred --docker-server=ghcr.io --docker-username="$GHCR_USERNAME" --docker-password="$GHCR_TOKEN" --dry-run=client -o yaml | kubectl apply -f - + kubectl -n "$K8S_NAMESPACE" patch deployment "$deployment" --type merge -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcr-regcred"}]}}}}' + kubectl -n "$K8S_NAMESPACE" set image deployment/"$deployment" "$container"="$image_ref" + kubectl -n "$K8S_NAMESPACE" rollout status deployment/"$deployment" --timeout=15m + + - name: Sync GitOps release + env: + GITOPS_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + run: | + set -euo pipefail + git clone "https://${{ secrets.GHCR_USERNAME }}:${GITOPS_TOKEN}@github.com/${GITOPS_REPO}.git" /tmp/nxtgauge-gitops + cd /tmp/nxtgauge-gitops + ./scripts/set-backend-rust-release.sh "${{ matrix.service }}" "${{ steps.build.outputs.digest }}" + if git diff --quiet; then + echo "GitOps repo already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apps/nxtgauge-backend-rust/overlays/prod/backend-release-state.tsv apps/nxtgauge-backend-rust/overlays/prod/release-patches.yaml apps/nxtgauge-backend-rust/overlays/prod/disabled-deployments.yaml + git commit -m "chore(gitops): deploy backend ${{ matrix.service }}@${{ github.sha }}" + git pull --rebase origin main + git push origin HEAD:main diff --git a/.github/workflows/sync-to-forgejo.yml b/.github/workflows/sync-to-forgejo.yml deleted file mode 100644 index 354a407..0000000 --- a/.github/workflows/sync-to-forgejo.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: sync-to-forgejo - -on: - push: - branches: - - main - - high-performance - -jobs: - sync: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push branch to Forgejo - env: - FORGEJO_SECRET: ${{ secrets.FORGEJO_SECRET || secrets.GITEA_SECRET }} - FORGEJO_OWNER: ${{ secrets.FORGEJO_OWNER || 'ashwin' }} - FORGEJO_USERNAME: ${{ secrets.FORGEJO_USERNAME || secrets.GITEA_USERNAME || 'ashwin' }} - REPO: ${{ github.event.repository.name }} - BRANCH: ${{ github.ref_name }} - run: | - set -euo pipefail - test -n "${FORGEJO_SECRET:-}" || { echo "FORGEJO_SECRET is empty"; exit 1; } - - AUTH="$(printf '%s' "${FORGEJO_USERNAME}:${FORGEJO_SECRET}" | base64 -w0)" - TARGET="https://ci.nxtgauge.com/${FORGEJO_OWNER}/${REPO}.git" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git remote remove forgejo 2>/dev/null || true - git remote add forgejo "${TARGET}" - - git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo "HEAD:${BRANCH}" --force - git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo --tags --force diff --git a/Dockerfile.simple b/Dockerfile.simple index aba206f..48e31c1 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -3,7 +3,7 @@ ARG SERVICE_NAME -FROM registry.nxtgauge.com/rust:alpine AS builder +FROM rust:alpine AS builder ARG SERVICE_NAME # Install build deps + rust toolchain (Alpine-packaged Rust lacks proc-macro support) From be6c23869189f2c345497c341b9ed9d6d24d29d8 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 22:57:54 +0530 Subject: [PATCH 3/6] fix(ci): use lowercase ghcr image names --- .github/workflows/build-and-deploy-ghcr.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml index 4e3e7c3..55eb784 100644 --- a/.github/workflows/build-and-deploy-ghcr.yml +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -99,7 +99,7 @@ print(json.dumps(services)) fail-fast: false max-parallel: 1 matrix: - service: ${{ fromJson(needs.detect-changes.outputs.services) }} + service: ${{ fromJSON(needs.detect-changes.outputs.services) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -126,7 +126,7 @@ print(json.dumps(services)) platforms: linux/amd64 build-args: | SERVICE_NAME=${{ matrix.service }} - tags: ghcr.io/${{ github.repository_owner }}/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} + tags: ghcr.io/traceworks2023/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} - name: Configure kubeconfig run: | @@ -146,7 +146,7 @@ print(json.dumps(services)) set -euo pipefail deployment="nxtgauge-rust-${{ matrix.service }}" container="${{ matrix.service }}" - image_ref="ghcr.io/${{ github.repository_owner }}/nxtgauge-rust-${{ matrix.service }}@${{ steps.build.outputs.digest }}" + image_ref="ghcr.io/traceworks2023/nxtgauge-rust-${{ matrix.service }}@${{ steps.build.outputs.digest }}" kubectl -n "$K8S_NAMESPACE" create secret docker-registry ghcr-regcred --docker-server=ghcr.io --docker-username="$GHCR_USERNAME" --docker-password="$GHCR_TOKEN" --dry-run=client -o yaml | kubectl apply -f - kubectl -n "$K8S_NAMESPACE" patch deployment "$deployment" --type merge -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcr-regcred"}]}}}}' kubectl -n "$K8S_NAMESPACE" set image deployment/"$deployment" "$container"="$image_ref" From 22ee7f8544115f4ccf4fadd34a104caeb844eccc Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 22:59:51 +0530 Subject: [PATCH 4/6] fix(ci): simplify backend github deployment workflow --- .github/workflows/build-and-deploy-ghcr.yml | 101 +++++--------------- 1 file changed, 22 insertions(+), 79 deletions(-) diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml index 55eb784..f03becd 100644 --- a/.github/workflows/build-and-deploy-ghcr.yml +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -16,90 +16,33 @@ env: GITOPS_REPO: Traceworks2023/nxtgauge-gitops jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - services: ${{ steps.detect.outputs.services }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Detect changed services - id: detect - shell: bash - run: | - set -euo pipefail - ALL_SERVICES='gateway users companies jobs leads job-seekers customers payments employees photographers makeup-artists tutors developers video-editors graphic-designers social-media-managers fitness-trainers catering-services ugc-content-creators cron' - - if git rev-parse --verify HEAD^ >/dev/null 2>&1; then - CHANGED_FILES="$(git diff --name-only HEAD^ HEAD)" - else - CHANGED_FILES="$(git ls-files)" - fi - - LAST_COMMIT_MSG="$(git log -1 --pretty=%B | tr '\n' ' ')" - FORCE_FULL_BUILD=false - if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger build|force build|rebuild all'; then - FORCE_FULL_BUILD=true - elif echo "$CHANGED_FILES" | grep -Eq '^(\.github/workflows/|\.forgejo/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then - FORCE_FULL_BUILD=true - fi - - if [ "$FORCE_FULL_BUILD" = true ]; then - SERVICES_JSON="$(printf '%s\n' $ALL_SERVICES | python3 -c 'import json,sys; print(json.dumps([line.strip() for line in sys.stdin if line.strip()]))')" - echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT" - exit 0 - fi - - SERVICES_JSON="$(printf '%s\n' "$CHANGED_FILES" | python3 -c ' -import json -import sys -mapping = { - "apps/gateway/": "gateway", - "apps/users/": "users", - "apps/companies/": "companies", - "apps/jobs/": "jobs", - "apps/leads/": "leads", - "apps/job_seekers/": "job-seekers", - "apps/customers/": "customers", - "apps/payments/": "payments", - "apps/employees/": "employees", - "apps/photographers/": "photographers", - "apps/makeup_artists/": "makeup-artists", - "apps/tutors/": "tutors", - "apps/developers/": "developers", - "apps/video_editors/": "video-editors", - "apps/graphic_designers/": "graphic-designers", - "apps/social_media_managers/": "social-media-managers", - "apps/fitness_trainers/": "fitness-trainers", - "apps/catering_services/": "catering-services", - "apps/ugc_content_creators/": "ugc-content-creators", - "apps/cron/": "cron", -} -services = [] -seen = set() -for raw in sys.stdin: - line = raw.strip() - for prefix, service in mapping.items(): - if line.startswith(prefix) and service not in seen: - seen.add(service) - services.append(service) - break -print(json.dumps(services)) -')" - echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT" - build-deploy: - needs: detect-changes - if: needs.detect-changes.outputs.services != '[]' runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 1 + max-parallel: 4 matrix: - service: ${{ fromJSON(needs.detect-changes.outputs.services) }} + service: + - gateway + - users + - companies + - jobs + - leads + - job-seekers + - customers + - payments + - employees + - photographers + - makeup-artists + - tutors + - developers + - video-editors + - graphic-designers + - social-media-managers + - fitness-trainers + - catering-services + - ugc-content-creators + - cron steps: - name: Checkout uses: actions/checkout@v4 From 74dad77614edf3e990f3a78016ddf6d303d5ada3 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 23:05:23 +0530 Subject: [PATCH 5/6] fix(ci): deploy via gitops from github actions --- .github/workflows/build-and-deploy-ghcr.yml | 24 --------------------- 1 file changed, 24 deletions(-) diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml index f03becd..a318b27 100644 --- a/.github/workflows/build-and-deploy-ghcr.yml +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -71,30 +71,6 @@ jobs: SERVICE_NAME=${{ matrix.service }} tags: ghcr.io/traceworks2023/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} - - name: Configure kubeconfig - run: | - set -euo pipefail - mkdir -p ~/.kube - printf '%s' '${{ secrets.KUBE_CONFIG_DATA }}' | base64 -d > ~/.kube/config - chmod 600 ~/.kube/config - - - name: Install kubectl - uses: azure/setup-kubectl@v4 - - - name: Deploy to Kubernetes - env: - GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} - GHCR_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} - run: | - set -euo pipefail - deployment="nxtgauge-rust-${{ matrix.service }}" - container="${{ matrix.service }}" - image_ref="ghcr.io/traceworks2023/nxtgauge-rust-${{ matrix.service }}@${{ steps.build.outputs.digest }}" - kubectl -n "$K8S_NAMESPACE" create secret docker-registry ghcr-regcred --docker-server=ghcr.io --docker-username="$GHCR_USERNAME" --docker-password="$GHCR_TOKEN" --dry-run=client -o yaml | kubectl apply -f - - kubectl -n "$K8S_NAMESPACE" patch deployment "$deployment" --type merge -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcr-regcred"}]}}}}' - kubectl -n "$K8S_NAMESPACE" set image deployment/"$deployment" "$container"="$image_ref" - kubectl -n "$K8S_NAMESPACE" rollout status deployment/"$deployment" --timeout=15m - - name: Sync GitOps release env: GITOPS_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} From ba63736e4674f4266686dacf8428c272b622cc54 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 14 Jun 2026 20:27:57 +0200 Subject: [PATCH 6/6] feat: add AI management endpoints and LiteLLM support User-facing AI endpoints: - GET /api/ai/usage/v2 - extended with addon_balance, renewal_date - POST /api/ai/addons/purchase - purchase addon packs - POST /api/ai/plans/upgrade - upgrade AI plans Admin AI endpoints: - GET /api/admin/ai/stats - AI usage statistics - GET /api/admin/ai/users - paginated user AI usage list - GET /api/admin/ai/plans - list AI plans Files: - apps/users/src/handlers/admin_ai.rs (new) --- apps/users/src/handlers/admin_ai.rs | 214 +++++++++++++++++++++++ apps/users/src/handlers/ai.rs | 255 +++++++++++++++++++++++++++- apps/users/src/handlers/mod.rs | 1 + apps/users/src/main.rs | 2 + 4 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 apps/users/src/handlers/admin_ai.rs diff --git a/apps/users/src/handlers/admin_ai.rs b/apps/users/src/handlers/admin_ai.rs new file mode 100644 index 0000000..1768c18 --- /dev/null +++ b/apps/users/src/handlers/admin_ai.rs @@ -0,0 +1,214 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +#[derive(Debug, Serialize)] +pub struct AiUsageStats { + pub total_users_with_ai: i64, + pub total_generations_today: i64, + pub total_generations_month: i64, + pub by_plan: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PlanUsage { + pub plan_name: String, + pub user_count: i64, + pub monthly_limit: i32, + pub total_monthly_usage: i64, +} + +#[derive(Debug, Serialize)] +pub struct UserAiUsage { + pub user_id: String, + pub email: String, + pub role: String, + pub plan_name: String, + pub monthly_limit: i32, + pub monthly_used: i32, + pub addon_balance: i32, + pub renewal_date: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UserUsageQuery { + pub user_id: Option, + pub role: Option, + pub page: Option, + pub limit: Option, +} + +pub fn admin_router() -> Router { + Router::new() + .route("/stats", get(ai_stats)) + .route("/users", get(ai_users_usage)) + .route("/plans", get(ai_plans_list)) +} + +async fn ai_stats( + State(state): State, +) -> Result, (StatusCode, String)> { + let today = chrono::Utc::now().date_naive(); + let start_of_month = { + let today_str = today.format("%Y-%m-%d").to_string(); + let year_month = &today_str[..7]; + let start_str = format!("{}-01", year_month); + chrono::NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or(today) + }; + + let total_users_with_ai: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT user_id) FROM ai_entitlements WHERE status = 'active'", + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total_generations_today: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0)::bigint FROM company_ai_usage WHERE usage_date = $1", + ) + .bind(today) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let seeker_today: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0)::bigint FROM job_seeker_ai_usage WHERE usage_date = $1", + ) + .bind(today) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total_generations_month_company: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0)::bigint FROM company_ai_usage WHERE usage_date >= $1", + ) + .bind(start_of_month) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total_generations_month_seeker: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0)::bigint FROM job_seeker_ai_usage WHERE usage_date >= $1", + ) + .bind(start_of_month) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let by_plan: Vec = sqlx::query_as::<_, (String, i64, i32, i64)>( + r#" + SELECT p.name, COUNT(DISTINCT e.user_id) as user_count, p.monthly_action_limit, 0 + FROM ai_entitlements e + JOIN ai_plans p ON p.id = e.plan_id + WHERE e.status = 'active' + GROUP BY p.name, p.monthly_action_limit + "#, + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_iter() + .map(|(name, count, limit, _)| PlanUsage { + plan_name: name, + user_count: count, + monthly_limit: limit, + total_monthly_usage: 0, + }) + .collect(); + + Ok(Json(AiUsageStats { + total_users_with_ai, + total_generations_today: total_generations_today + seeker_today, + total_generations_month: total_generations_month_company + total_generations_month_seeker, + by_plan, + })) +} + +async fn ai_users_usage( + State(state): State, + Query(query): Query, +) -> Result>, (StatusCode, String)> { + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(50).min(100); + let offset = (page - 1) * limit; + + let users: Vec = sqlx::query_as::<_, (String, String, String, String, i32, i32, i32, Option)>( + r#" + SELECT + u.id as user_id, + u.email, + COALESCE( + (SELECT role_key FROM user_role_profiles urp WHERE urp.user_id = u.id LIMIT 1), + 'UNKNOWN' + ) as role, + COALESCE(p.name, 'Free') as plan_name, + COALESCE(e.monthly_action_limit, 50) as monthly_limit, + COALESCE(e.monthly_used_actions, 0) as monthly_used, + COALESCE(e.addon_balance, 0) as addon_balance, + e.renewal_date + FROM users u + LEFT JOIN ai_entitlements e ON e.user_id = u.id AND e.status = 'active' + LEFT JOIN ai_plans p ON p.id = e.plan_id + WHERE EXISTS (SELECT 1 FROM ai_entitlements WHERE user_id = u.id AND status = 'active') + ORDER BY u.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_iter() + .map(|(user_id, email, role, plan, limit, used, addon, renewal)| UserAiUsage { + user_id, + email, + role, + plan_name: plan, + monthly_limit: limit, + monthly_used: used, + addon_balance: addon, + renewal_date: renewal.map(|d| d.format("%Y-%m-%d").to_string()), + }) + .collect(); + + Ok(Json(users)) +} + +async fn ai_plans_list( + State(state): State, +) -> Result>, (StatusCode, String)> { + let plans: Vec = sqlx::query_as::<_, (String, String, i32, Option, bool)>( + "SELECT id::text, name, monthly_action_limit, daily_action_limit, is_active FROM ai_plans ORDER BY monthly_action_limit ASC", + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_iter() + .map(|(id, name, monthly, daily, active)| AiPlan { + id, + name, + monthly_action_limit: monthly, + daily_action_limit: daily, + is_active: active, + }) + .collect(); + + Ok(Json(plans)) +} + +#[derive(Debug, Serialize)] +pub struct AiPlan { + pub id: String, + pub name: String, + pub monthly_action_limit: i32, + pub daily_action_limit: Option, + pub is_active: bool, +} diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 681f48f..37007f9 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -3323,12 +3323,18 @@ pub mod phase3 { /// GET /api/ai/usage — counts, limits, remaining quota. /// Per-user, per-minute rate-limit window + Redis daily counter + DB counter. + /// Extended with plan info, addon_balance, and renewal_date from ai_entitlements. pub async fn ai_usage( State(state): State, auth: contracts::auth_middleware::AuthUser, ) -> impl axum::response::IntoResponse { - // Daily usage from DB (company_ai_usage or job_seeker_ai_usage). let today = chrono::Utc::now().date_naive(); + let start_of_month = { + let today_str = today.format("%Y-%m-%d").to_string(); + let year_month = &today_str[..7]; + let start_str = format!("{}-01", year_month); + chrono::NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or(today) + }; let company_used: Option = sqlx::query_scalar( "SELECT generations_used FROM company_ai_usage WHERE company_id = \ @@ -3354,7 +3360,47 @@ pub mod phase3 { .flatten() .flatten(); - // Per-minute usage from Redis (today's minute bucket). + let company_monthly_used: Option = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0) FROM company_ai_usage WHERE company_id = \ + (SELECT id FROM company_profiles WHERE user_id = $1) AND usage_date >= $2", + ) + .bind(auth.user_id) + .bind(start_of_month) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .flatten(); + + let seeker_monthly_used: Option = sqlx::query_scalar( + "SELECT COALESCE(SUM(generations_used), 0) FROM job_seeker_ai_usage WHERE job_seeker_id = \ + (SELECT id FROM job_seeker_profiles WHERE user_id = $1) AND usage_date >= $2", + ) + .bind(auth.user_id) + .bind(start_of_month) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .flatten(); + + let (plan_name, monthly_limit, addon_balance, renewal_date): (String, i32, i32, Option) = sqlx::query_as::<_, (String, i32, i32, Option)>( + r#" + SELECT p.name, e.monthly_limit, e.addon_balance, e.renewal_date + FROM ai_entitlements e + JOIN ai_plans p ON p.id = e.plan_id + WHERE e.user_id = $1 + ORDER BY e.created_at DESC + LIMIT 1 + "#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or_else(|| ("Free".to_string(), 50, 0, None)); + let mut redis = state.redis.clone(); let now_minute = chrono::Utc::now().timestamp() / 60; let chat_key = format!("rl:ai_chat:{}:{}", auth.user_id, now_minute); @@ -3365,17 +3411,21 @@ pub mod phase3 { let stream_minute: i64 = redis.get(&stream_key).await.unwrap_or(0); let daily_used = company_used.or(seeker_used).unwrap_or(0); - let daily_limit = super::BASE_AI_LIMIT; // Could be lifted if user has an AI pack. + let monthly_used = company_monthly_used.unwrap_or(0) + seeker_monthly_used.unwrap_or(0); + let daily_limit = super::BASE_AI_LIMIT; ( axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "user_id": auth.user_id, - "daily": { - "used": daily_used, - "limit": daily_limit, - "remaining": (daily_limit - daily_used).max(0), - }, + "plan": plan_name, + "monthly_limit": monthly_limit, + "monthly_used": monthly_used, + "monthly_remaining": (monthly_limit - monthly_used).max(0), + "daily_limit": daily_limit, + "daily_used": daily_used, + "addon_balance": addon_balance, + "renewal_date": renewal_date, "rate_limits": { "chat_per_minute": { "used": chat_minute, @@ -3422,6 +3472,192 @@ pub mod phase3 { } } + #[derive(Debug, Deserialize)] + pub struct AddonPurchaseBody { + pub addon_code: String, + } + + #[derive(Debug, Serialize)] + pub struct AddonPurchaseResponse { + pub success: bool, + pub addon_balance: i32, + pub message: String, + } + + /// POST /api/ai/addons/purchase — purchase an addon pack to add generations + pub async fn ai_addon_purchase( + State(state): State, + auth: contracts::auth_middleware::AuthUser, + Json(body): Json, + ) -> impl axum::response::IntoResponse { + let addon_amount: i32 = match body.addon_code.as_str() { + "STARTER" => 100, + "PRO" => 500, + "ENTERPRISE" => 2000, + _ => { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "success": false, + "message": format!("Unknown addon code: {}", body.addon_code), + })), + ) + .into_response() + } + }; + + let result = sqlx::query_as::<_, (i32,)>( + r#" + UPDATE ai_entitlements + SET addon_balance = addon_balance + $1, updated_at = NOW() + WHERE user_id = $2 AND status = 'active' + RETURNING addon_balance + "#, + ) + .bind(addon_amount) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await; + + match result { + Ok(Some((new_balance,))) => ( + axum::http::StatusCode::OK, + axum::Json(AddonPurchaseResponse { + success: true, + addon_balance: new_balance, + message: format!("Successfully purchased {} generations", addon_amount), + }), + ) + .into_response(), + Ok(None) => ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(AddonPurchaseResponse { + success: false, + addon_balance: 0, + message: "No active AI entitlement found. Please upgrade your plan first.".to_string(), + }), + ) + .into_response(), + Err(e) => { + tracing::error!("ai_addon_purchase failed: {}", e); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(AddonPurchaseResponse { + success: false, + addon_balance: 0, + message: "Failed to process purchase".to_string(), + }), + ) + .into_response() + } + } + } + + #[derive(Debug, Deserialize)] + pub struct PlanUpgradeBody { + pub plan_code: String, + } + + #[derive(Debug, Serialize)] + pub struct PlanUpgradeResponse { + pub success: bool, + pub plan: String, + pub monthly_limit: i32, + pub message: String, + } + + /// POST /api/ai/plans/upgrade — upgrade the user's AI plan + pub async fn ai_plan_upgrade( + State(state): State, + auth: contracts::auth_middleware::AuthUser, + Json(body): Json, + ) -> impl axum::response::IntoResponse { + let (plan_id, plan_name, monthly_limit): (uuid::Uuid, String, i32) = sqlx::query_as( + r#" + SELECT id, name, monthly_action_limit + FROM ai_plans + WHERE code = $1 AND is_active = true + "#, + ) + .bind(&body.plan_code) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(id, name, limit): (uuid::Uuid, String, i32)| (id, name, limit)) + .unwrap_or_else(|| (uuid::Uuid::nil(), "Free".to_string(), 50)); + + if plan_id == uuid::Uuid::nil() { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(PlanUpgradeResponse { + success: false, + plan: "Free".to_string(), + monthly_limit: 50, + message: format!("Unknown or inactive plan: {}", body.plan_code), + }), + ) + .into_response(); + } + + let result = sqlx::query_as::<_, (String, i32)>( + r#" + UPDATE ai_entitlements + SET plan_id = $1, + monthly_action_limit = $2, + valid_from = NOW(), + updated_at = NOW() + WHERE user_id = $3 AND status = 'active' + RETURNING p.name, e.monthly_action_limit + FROM ai_plans p + WHERE p.id = e.plan_id + "#, + ) + .bind(plan_id) + .bind(monthly_limit) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await; + + match result { + Ok(Some((name, limit))) => ( + axum::http::StatusCode::OK, + axum::Json(PlanUpgradeResponse { + success: true, + plan: name, + monthly_limit: limit, + message: format!("Successfully upgraded to {}", plan_name), + }), + ) + .into_response(), + Ok(None) => { + ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(PlanUpgradeResponse { + success: false, + plan: plan_name, + monthly_limit, + message: "No active AI entitlement found. Please purchase a plan first.".to_string(), + }), + ) + .into_response() + } + Err(e) => { + tracing::error!("ai_plan_upgrade failed: {}", e); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(PlanUpgradeResponse { + success: false, + plan: plan_name, + monthly_limit, + message: "Failed to upgrade plan".to_string(), + }), + ) + .into_response() + } + } + } + // ── Unit tests ─────────────────────────────────────────────────────────── #[cfg(test)] @@ -3528,6 +3764,9 @@ pub fn ai_router() -> Router { .route("/feedback", post(phase3::ai_feedback)) .route("/usage/v2", get(phase3::ai_usage)) .route("/clear-history", axum::routing::post(phase3::ai_clear_history)) + // ── Addon purchase and plan upgrade ────────────────────────────────── + .route("/addons/purchase", post(phase3::ai_addon_purchase)) + .route("/plans/upgrade", post(phase3::ai_plan_upgrade)) // ── Phase 4: multi-lang, voice, A/B, analytics, model swap, KB+ ─── .merge(crate::handlers::ai_phase4::phase4_router()) } diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 694c0f6..767b927 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod admin_ai; pub mod admin_email; pub mod activity_logs; pub mod approvals; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index ce8dc43..8027334 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -109,6 +109,8 @@ async fn main() { .nest("/api/admin/reports", handlers::pricing::reports_router()) // ── Email Management (admin) ────────────────────────────────────── .nest("/api/admin/email", handlers::admin_email::router()) + // ── AI Management (admin) ──────────────────────────────────────── + .nest("/api/admin/ai", handlers::admin_ai::admin_router()) // ── AI Assistant ────────────────────────────────────────────────── .nest("/api/ai", handlers::ai::ai_router()) .route("/health", get(|| async { "Users OK" }))