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" }))