feat(ai): complete AI plans/credits implementation and build tooling

- Add AI plans, credits, model routing, LiteLLM client, and orchestrator services
- Add AI management endpoints, auto-apply/auto-request handlers, and log endpoints
- Add cron jobs for daily action reset and monthly credit reset
- Add AI credit purchase flow in payments service
- Add ai_credit_packages migration with seed data
- Update Dockerfile build tooling across services
This commit is contained in:
Ashwin Kumar Sivakumar 2026-06-15 06:15:49 +05:30
parent ccf57df0c7
commit c85e6af22e
61 changed files with 4622 additions and 245 deletions

22
Cargo.lock generated
View file

@ -111,7 +111,7 @@ dependencies = [
"axum",
"chrono",
"db",
"jsonwebtoken",
"jsonwebtoken 10.4.0",
"rand_core 0.6.4",
"serde",
"tokio",
@ -847,7 +847,7 @@ dependencies = [
"cache",
"chrono",
"db",
"jsonwebtoken",
"jsonwebtoken 10.4.0",
"serde",
"serde_json",
"sqlx",
@ -2174,6 +2174,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "jsonwebtoken"
version = "10.4.0"
@ -4254,6 +4269,7 @@ dependencies = [
"db",
"email",
"futures",
"jsonwebtoken 9.3.1",
"rand 0.8.6",
"redis",
"regex",
@ -4261,7 +4277,9 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"thiserror",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"uuid",

View file

@ -1,7 +1,7 @@
# Base image with all dependencies pre-compiled
# Build once, use for all services
FROM rust:alpine AS chef
FROM registry.nxtgauge.com/rust:alpine AS chef
RUN apk add --no-cache musl-dev pkgconfig openssl-dev && \
rustup target add x86_64-unknown-linux-musl && \
cargo install cargo-chef

View file

@ -2,7 +2,7 @@
# Build: docker build --build-arg SERVICE_NAME=users -f Dockerfile.fast .
# Stage 1: Chef - Prepare dependency recipe
FROM rust:alpine AS chef
FROM registry.nxtgauge.com/rust:alpine AS chef
RUN apk add --no-cache musl-dev pkgconfig openssl-dev && \
rustup target add x86_64-unknown-linux-musl && \
cargo install cargo-chef

View file

@ -19,7 +19,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin db-migrate --target x86_64-unknown-linux-musl
FROM alpine:3.19
FROM registry.nxtgauge.com/alpine:3.20
RUN apk add --no-cache ca-certificates libpq
COPY --from=builder /app/crates/db-migrate/target/x86_64-unknown-linux-musl/release/db-migrate /usr/local/bin/

View file

@ -2,8 +2,8 @@
# Usage: docker build --build-arg SERVICE_NAME=gateway -t nxtgauge-gateway .
# Stage 1: Base builder with dependencies cached
FROM rust:alpine AS chef
RUN apk add --no-cache musl-dev pkgconfig openssl-dev && \
FROM registry.nxtgauge.com/rust:alpine AS chef
RUN apk add --no-cache musl-dev pkgconfig openssl-dev ca-certificates && \
rustup target add x86_64-unknown-linux-musl && \
cargo install cargo-chef
WORKDIR /app
@ -33,15 +33,14 @@ COPY apps/ ./apps/
ENV RUSTFLAGS='-C target-feature=+crt-static'
RUN cargo build --release --bin ${SERVICE_NAME} --target x86_64-unknown-linux-musl
# Stage 4: Runtime - minimal distroless image
FROM gcr.io/distroless/static:nonroot
# Stage 4: Runtime - scratch image with local builder-provided certificates
FROM scratch
ARG SERVICE_NAME
# Copy only the binary
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/${SERVICE_NAME} /app/service
# Use nonroot user (65532:65532 in distroless)
USER nonroot:nonroot
USER 65532:65532
EXPOSE 8000

View file

@ -4,8 +4,8 @@
ARG SERVICE_NAME
# Stage 1: Use pre-built base with all dependencies cached
# Build base with: docker build -f Dockerfile.base -t nxtgauge-rust-base:latest .
FROM ghcr.io/traceworks2023/nxtgauge-rust-base:latest AS builder
# Build base with: docker build -f Dockerfile.base -t registry.nxtgauge.com/nxtgauge-rust-base:latest .
FROM registry.nxtgauge.com/nxtgauge-rust-base:latest AS builder
ARG SERVICE_NAME
WORKDIR /app

View file

@ -1,5 +1,5 @@
# Build stage
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -17,7 +17,7 @@ ENV RUSTFLAGS='-C target-feature=+crt-static'
RUN cargo build --release --bin ${BIN_NAME} --target x86_64-unknown-linux-musl
# Runtime stage - minimal Alpine
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
# Install CA certificates only
RUN apk add --no-cache ca-certificates

View file

@ -5,7 +5,7 @@
ARG SERVICE_NAME
# Use the pre-built base image with all dependencies cached
FROM ghcr.io/traceworks2023/nxtgauge-rust-base:latest AS builder
FROM registry.nxtgauge.com/nxtgauge-rust-base:latest AS builder
ARG SERVICE_NAME
WORKDIR /app

40
Dockerfile.working Normal file
View file

@ -0,0 +1,40 @@
# Simple fast Dockerfile - preserves full workspace
FROM registry.nxtgauge.com/rust:alpine AS builder
# Install build deps
RUN apk add --no-cache curl ca-certificates bash build-base musl-dev pkgconfig openssl-dev openssl-libs-static
RUN update-ca-certificates
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app
ARG SERVICE_NAME
# Copy full workspace (for dependencies)
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
COPY apps/ ./apps/
# Build with all optimizations
ENV RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-s"
ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN svc=$(echo "${SERVICE_NAME}" | tr '-' '_') && \
cargo build --release \
--bin ${svc} \
--target x86_64-unknown-linux-musl && \
cp /app/target/x86_64-unknown-linux-musl/release/${svc} /app/service
# Runtime
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/service /app/service
USER 65532:65532
EXPOSE 8000
ENTRYPOINT ["/app/service"]

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin catering_services --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin companies --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin cron --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -75,6 +75,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
// Spawn Daily AI reset task (resets daily actions and monthly credits)
let p_ai_sys = pool.clone();
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(24 * 60 * 60));
loop {
interval.tick().await;
tracing::info!("Running AI Credit Reset Task...");
if let Err(e) = tasks::ai::reset_daily_actions(&p_ai_sys).await {
tracing::error!("AI daily reset task failed: {}", e);
}
if let Err(e) = tasks::ai::reset_monthly_credits(&p_ai_sys).await {
tracing::error!("AI monthly reset task failed: {}", e);
}
}
});
// Keep main thread alive
tokio::signal::ctrl_c().await?;
tracing::info!("Shutting down cron engine.");

40
apps/cron/src/tasks/ai.rs Normal file
View file

@ -0,0 +1,40 @@
use sqlx::PgPool;
/// Reset daily_actions_used for all subscriptions every day.
pub async fn reset_daily_actions(pool: &PgPool) -> Result<(), sqlx::Error> {
let rows = sqlx::query(
"UPDATE user_ai_subscriptions SET daily_actions_used = 0, updated_at = NOW()"
)
.execute(pool)
.await?
.rows_affected();
tracing::info!("Reset daily_actions_used for {} AI subscriptions", rows);
Ok(())
}
/// Reset monthly credits at the start of each billing period. This is a
/// conservative implementation that resets credits for any subscription whose
/// current_period_end has passed, and rolls the period forward by one month.
pub async fn reset_monthly_credits(pool: &PgPool) -> Result<(), sqlx::Error> {
let now = chrono::Utc::now();
let rows = sqlx::query(
r#"
UPDATE user_ai_subscriptions
SET monthly_credits_used = 0,
daily_actions_used = 0,
current_period_start = current_period_end,
current_period_end = current_period_end + INTERVAL '1 month',
updated_at = NOW()
WHERE current_period_end <= $1
"#
)
.bind(now)
.execute(pool)
.await?
.rows_affected();
tracing::info!("Rolled monthly credits for {} AI subscriptions", rows);
Ok(())
}

View file

@ -1,3 +1,4 @@
pub mod ai;
pub mod leads;
pub mod requirements;
pub mod jobs;

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin customers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin developers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin employees --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin fitness_trainers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -16,7 +16,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin gateway --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin graphic_designers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin job_seekers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin jobs --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin leads --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin makeup_artists --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin payments --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -0,0 +1,374 @@
use crate::AppState;
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct CreateAiCreditOrderRequest {
package_id: Uuid,
}
#[derive(Debug, Deserialize, Serialize)]
struct VerifyAiCreditOrderRequest {
order_id: String,
payment_id: String,
signature: Option<String>,
}
#[derive(Debug, Serialize, FromRow)]
struct AiCreditPackageRow {
id: Uuid,
name: String,
description: Option<String>,
credits: i32,
price_inr: i32,
}
pub fn ai_credits_router() -> Router<AppState> {
Router::new()
.route("/ai-credits", get(list_ai_credit_packages))
.route("/ai-credits/order", post(create_ai_credit_order))
.route("/ai-credits/verify", post(verify_ai_credit_order))
}
async fn list_ai_credit_packages(State(state): State<AppState>) -> impl IntoResponse {
let rows = sqlx::query_as::<_, AiCreditPackageRow>(
r#"
SELECT id, name, description, credits, price_inr
FROM ai_credit_packages
WHERE is_active = true
ORDER BY price_inr ASC
"#,
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "packages": rows }))).into_response(),
Err(e) => {
tracing::error!("Failed to list AI credit packages: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn create_ai_credit_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateAiCreditOrderRequest>,
) -> impl IntoResponse {
let package = match sqlx::query_as::<_, AiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true"
)
.bind(payload.package_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(p)) => p,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Invalid or inactive AI credit package" })),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to fetch AI credit package: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
let resp = state
.client
.post(&state.beeceptor_url)
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"amount": package.price_inr * 100,
"currency": "INR",
"package_id": package.id.to_string(),
"user_id": auth.user_id.to_string(),
"package_type": "AI_CREDITS"
}))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
tracing::error!("Beeceptor error creating AI credit order: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Payment gateway error" })),
)
.into_response();
}
};
let status = resp.status();
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
tracing::error!("Failed to parse Beeceptor response: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Invalid payment gateway response" })),
)
.into_response();
}
};
if !status.is_success() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": body.get("message").and_then(|m| m.as_str()).unwrap_or("Order creation failed")
})),
)
.into_response();
}
let order_id = body
.get("order_id")
.and_then(|v| v.as_str())
.unwrap_or("mock_ai_credit_order")
.to_string();
if let Err(e) = sqlx::query(
r#"
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, 0, 'PENDING')
"#,
)
.bind(auth.user_id)
.bind(package.id)
.bind(&order_id)
.bind(package.price_inr)
.execute(&state.pool)
.await
{
tracing::error!("Failed to record AI credit payment: {}", e);
}
(
StatusCode::OK,
Json(serde_json::json!({
"order_id": order_id,
"amount": package.price_inr * 100,
"currency": "INR",
"credits": package.credits,
"status": "created"
})),
)
.into_response()
}
async fn verify_ai_credit_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<VerifyAiCreditOrderRequest>,
) -> impl IntoResponse {
let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/'));
let resp = match state
.client
.post(&verify_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("Beeceptor verify error: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Payment gateway error" })),
)
.into_response();
}
};
let status = resp.status();
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
tracing::error!("Failed to parse verify response: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Invalid payment gateway response" })),
)
.into_response();
}
};
if !status.is_success() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": body.get("message").and_then(|m| m.as_str()).unwrap_or("Verification failed")
})),
)
.into_response();
}
let payment = match sqlx::query_as::<_, crate::PaymentRow>(
r#"
SELECT id, user_id, package_id, tracecoins_credited
FROM payments
WHERE razorpay_order_id = $1 AND status = 'PENDING'
"#,
)
.bind(&payload.order_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(p)) => p,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Payment not found or already processed" })),
)
.into_response()
}
Err(e) => {
tracing::error!("Database error fetching payment: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
if payment.user_id != auth.user_id {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "Payment does not belong to user" })),
)
.into_response();
}
let package = match sqlx::query_as::<_, AiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true"
)
.bind(payment.package_id.unwrap_or_default())
.fetch_optional(&state.pool)
.await
{
Ok(Some(p)) => p,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "AI credit package not found" })),
)
.into_response();
}
};
if let Err(e) = sqlx::query(
r#"
UPDATE payments SET
status = 'SUCCESS',
razorpay_payment_id = $1,
verified_at = NOW()
WHERE id = $2
"#,
)
.bind(&payload.payment_id)
.bind(payment.id)
.execute(&state.pool)
.await
{
tracing::error!("Failed to update payment status: {}", e);
}
// Credit the user via the users service admin endpoint.
let users_service_url = std::env::var("USERS_SERVICE_URL")
.unwrap_or_else(|_| "http://nxtgauge-rust-users:9101".to_string());
let credit_url = format!("{}/api/admin/ai/users/{}/credits", users_service_url.trim_end_matches('/'), auth.user_id);
let admin_token = match std::env::var("AI_CREDIT_ADMIN_TOKEN") {
Ok(t) => t,
Err(_) => {
tracing::error!("AI_CREDIT_ADMIN_TOKEN not set; cannot credit AI credits automatically");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "AI credit crediting is not configured" })),
)
.into_response();
}
};
let credit_resp = match state
.client
.post(&credit_url)
.header("Authorization", format!("Bearer {}", admin_token))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"credits": package.credits,
"source": "purchase",
"description": format!("AI credit package {}", package.id)
}))
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to call users service to credit AI credits: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Failed to credit AI credits" })),
)
.into_response();
}
};
if !credit_resp.status().is_success() {
let err_body = credit_resp.text().await.unwrap_or_default();
tracing::error!("Users service rejected AI credit grant: {}", err_body);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Failed to credit AI credits" })),
)
.into_response();
}
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(auth.user_id)
.bind("AI Credits Purchased")
.bind(format!("Your {} AI credits have been added to your account.", package.credits))
.bind("AI_CREDITS")
.bind(payment.id)
.execute(&state.pool)
.await;
(
StatusCode::OK,
Json(serde_json::json!({
"verified": true,
"credits_added": package.credits,
"payment_id": payload.payment_id,
"status": "success"
})),
)
.into_response()
}

View file

@ -12,6 +12,7 @@ use uuid::Uuid;
use sqlx::postgres::PgPool;
use sqlx::FromRow;
pub mod ai_credits;
pub mod packages;
#[derive(Clone)]
@ -361,6 +362,7 @@ async fn main() {
.route("/api/payments/verify", post(verify_payment))
.route("/api/payments/{id}/status", get(get_payment_status))
.nest("/api/packages", packages::router())
.nest("/api/ai-credits", ai_credits::ai_credits_router())
.with_state(state);
let port: u16 = std::env::var("PORT")

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin photographers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin social_media_managers --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin tutors --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin ugc_content_creators --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -25,4 +25,7 @@ regex = { workspace = true }
redis = { workspace = true }
futures = "0.3"
async-stream = "0.3"
thiserror = { workspace = true }
jsonwebtoken = "9"
tower = "0.5"

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin users --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -0,0 +1,154 @@
use axum::http::StatusCode;
use db::models::ai::{
AiCreditTransactionRepository, AiFeatureCost, AiFeatureCostRepository, AiPlan, AiPlanRepository,
UserAiSubscription, UserAiSubscriptionRepository,
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum CreditError {
#[error("Database error: {0}")]
Db(#[from] sqlx::Error),
#[error("Unknown feature '{feature}'")]
UnknownFeature { feature: String },
#[error("Insufficient AI credits")]
InsufficientCredits,
#[error("Daily AI action limit reached")]
DailyActionLimitReached,
}
impl CreditError {
pub fn status_code(&self) -> StatusCode {
match self {
CreditError::InsufficientCredits => StatusCode::PAYMENT_REQUIRED,
CreditError::DailyActionLimitReached => StatusCode::TOO_MANY_REQUESTS,
CreditError::UnknownFeature { .. } => StatusCode::BAD_REQUEST,
CreditError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn error_body(&self) -> serde_json::Value {
serde_json::json!({
"error": self.to_string(),
"code": match self {
CreditError::InsufficientCredits => "INSUFFICIENT_AI_CREDITS",
CreditError::DailyActionLimitReached => "AI_DAILY_LIMIT_REACHED",
CreditError::UnknownFeature { .. } => "UNKNOWN_AI_FEATURE",
CreditError::Db(_) => "INTERNAL_ERROR",
}
})
}
}
/// Remaining monthly credits (plan + purchased, minus used).
pub fn remaining_credits(sub: &UserAiSubscription) -> i32 {
let total = sub.monthly_credits_total + sub.purchased_credits_total;
let used = sub.monthly_credits_used + sub.purchased_credits_used;
total.saturating_sub(used)
}
pub fn remaining_daily_actions(sub: &UserAiSubscription, plan: &AiPlan) -> i32 {
plan.daily_action_limit.saturating_sub(sub.daily_actions_used)
}
pub async fn get_feature_cost(
pool: &PgPool,
feature_code: &str,
) -> Result<AiFeatureCost, CreditError> {
AiFeatureCostRepository::get_by_code(pool, feature_code)
.await?
.ok_or_else(|| CreditError::UnknownFeature {
feature: feature_code.to_string(),
})
}
/// Charge credits for an AI feature. Increments daily_actions_used and
/// monthly_credits_used. Logs the transaction and usage. Returns the
/// resolved feature cost and model alias.
#[allow(clippy::too_many_arguments)]
pub async fn charge_feature(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
requested_model: Option<&str>,
) -> Result<AiFeatureCost, CreditError> {
let cost = get_feature_cost(pool, feature_code).await?;
let model_alias = requested_model.unwrap_or(&cost.default_model);
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)
.await?
.ok_or(CreditError::InsufficientCredits)?;
let plan = AiPlanRepository::get_by_id(pool, sub.plan_id)
.await?
.ok_or(CreditError::InsufficientCredits)?;
// Check daily action limit.
if sub.daily_actions_used >= plan.daily_action_limit {
return Err(CreditError::DailyActionLimitReached);
}
// Check credits.
if remaining_credits(&sub) < cost.credit_cost {
return Err(CreditError::InsufficientCredits);
}
// Charge.
UserAiSubscriptionRepository::charge_credits(pool, user_id, cost.credit_cost).await?;
UserAiSubscriptionRepository::increment_daily_actions(pool, user_id).await?;
// Record transaction.
let balance_after = remaining_credits(&sub) - cost.credit_cost;
AiCreditTransactionRepository::create(
pool,
user_id,
"debit",
"usage",
-cost.credit_cost,
balance_after,
None,
Some(&format!("feature={}", feature_code)),
)
.await?;
let _ = super::usage::log_usage(
pool,
user_id,
role_code,
feature_code,
model_alias,
cost.credit_cost,
None,
None,
None,
"success",
None,
None,
)
.await;
Ok(AiFeatureCost {
id: cost.id,
feature_code: cost.feature_code,
display_name: cost.display_name,
default_model: model_alias.to_string(),
credit_cost: cost.credit_cost,
max_input_tokens: cost.max_input_tokens,
max_output_tokens: cost.max_output_tokens,
is_active: cost.is_active,
created_at: cost.created_at,
updated_at: cost.updated_at,
})
}
/// Validate that a user could afford a feature without charging. Useful for
/// pre-flight checks in streaming endpoints.
pub async fn can_afford(pool: &PgPool, user_id: Uuid, feature_code: &str) -> Result<bool, CreditError> {
let cost = get_feature_cost(pool, feature_code).await?;
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)
.await?
.ok_or(CreditError::InsufficientCredits)?;
Ok(remaining_credits(&sub) >= cost.credit_cost)
}

View file

@ -0,0 +1,215 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmChatRequest {
pub model: String,
pub messages: Vec<LiteLlmChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmUsage {
pub prompt_tokens: Option<i32>,
pub completion_tokens: Option<i32>,
pub total_tokens: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmChoice {
pub index: Option<i32>,
pub message: Option<LiteLlmChatMessage>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmChatResponse {
pub id: Option<String>,
pub model: Option<String>,
pub choices: Vec<LiteLlmChoice>,
pub usage: Option<LiteLlmUsage>,
}
#[derive(Debug, thiserror::Error)]
pub enum LiteLlmError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("LiteLLM returned error: {status} - {body}")]
Api { status: u16, body: String },
#[error("No completion returned")]
NoCompletion,
#[error("Configuration error: missing LiteLLM base URL")]
MissingBaseUrl,
}
impl LiteLlmError {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
LiteLlmError::Http(_) => StatusCode::BAD_GATEWAY,
LiteLlmError::Api { status, .. } => StatusCode::from_u16(*status)
.unwrap_or(StatusCode::BAD_GATEWAY),
LiteLlmError::NoCompletion => StatusCode::BAD_GATEWAY,
LiteLlmError::MissingBaseUrl => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn error_body(&self) -> serde_json::Value {
serde_json::json!({
"error": self.to_string(),
"code": "LITELLM_ERROR"
})
}
}
pub struct LiteLlmClient {
client: Client,
base_url: String,
api_key: Option<String>,
}
impl LiteLlmClient {
pub fn new() -> Result<Self, LiteLlmError> {
let base_url = std::env::var("LITELLM_BASE_URL")
.unwrap_or_else(|_| "http://litellm.nxtgauge-ai.svc.cluster.local:4000".to_string());
if base_url.is_empty() {
return Err(LiteLlmError::MissingBaseUrl);
}
let api_key = std::env::var("LITELLM_API_KEY").ok();
Ok(Self {
client: Client::new(),
base_url,
api_key,
})
}
pub fn from_url(base_url: String, api_key: Option<String>) -> Self {
Self {
client: Client::new(),
base_url,
api_key,
}
}
pub async fn chat_completion(
&self,
request: LiteLlmChatRequest,
) -> Result<LiteLlmChatResponse, LiteLlmError> {
let url = format!("{}/v1/chat/completions", self.base_url);
let mut req = self.client.post(&url).json(&request);
if let Some(key) = &self.api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(LiteLlmError::Api {
status: status.as_u16(),
body,
});
}
let result = response.json::<LiteLlmChatResponse>().await?;
Ok(result)
}
pub async fn chat_completion_text(
&self,
model: &str,
system_prompt: Option<&str>,
user_message: &str,
max_tokens: Option<i32>,
) -> Result<(String, LiteLlmUsage, Option<String>), LiteLlmError> {
let mut messages = Vec::new();
if let Some(system) = system_prompt {
messages.push(LiteLlmChatMessage {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(LiteLlmChatMessage {
role: "user".to_string(),
content: user_message.to_string(),
});
let request = LiteLlmChatRequest {
model: model.to_string(),
messages,
temperature: Some(0.7),
max_tokens,
stream: Some(false),
user: None,
};
let response = self.chat_completion(request).await?;
let request_id = response.id.clone();
let usage = response.usage.unwrap_or(LiteLlmUsage {
prompt_tokens: None,
completion_tokens: None,
total_tokens: None,
});
let content = response
.choices
.into_iter()
.next()
.and_then(|c| c.message)
.map(|m| m.content)
.ok_or(LiteLlmError::NoCompletion)?;
Ok((content, usage, request_id))
}
/// Direct pass-through for callers that want the raw JSON response.
pub async fn raw_chat_completion(&self, body: Value) -> Result<Value, LiteLlmError> {
let url = format!("{}/v1/chat/completions", self.base_url);
let mut req = self.client.post(&url).json(&body);
if let Some(key) = &self.api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(LiteLlmError::Api {
status: status.as_u16(),
body,
});
}
let result = response.json::<Value>().await?;
Ok(result)
}
}
impl Default for LiteLlmClient {
fn default() -> Self {
Self::new().unwrap_or_else(|e| {
tracing::error!("Failed to create default LiteLlmClient: {}", e);
Self::from_url("http://localhost:4000".to_string(), None)
})
}
}

View file

@ -0,0 +1,164 @@
use axum::extract::{FromRequestParts, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use contracts::auth_middleware::AuthUser;
use sqlx::PgPool;
use std::future::Future;
use uuid::Uuid;
use crate::ai::{credits, plans};
use crate::AppState;
/// Extractor that ensures the user has an active AI subscription and is not a
/// customer. It can be combined with `AuthUser` in handlers that need it.
#[derive(Debug, Clone)]
pub struct AiAccess {
pub user_id: Uuid,
pub role_code: Option<String>,
pub subscription: db::models::ai::UserAiSubscription,
pub plan: db::models::ai::AiPlan,
}
impl AiAccess {
/// Remaining credits from the user's subscription.
pub fn remaining_credits(&self) -> i32 {
credits::remaining_credits(&self.subscription)
}
/// Remaining daily AI actions from the user's subscription.
pub fn remaining_daily_actions(&self) -> i32 {
credits::remaining_daily_actions(&self.subscription, &self.plan)
}
/// Whether a feature is allowed by the current plan.
pub fn feature_allowed(&self, feature_code: &str) -> bool {
plans::is_feature_allowed(&self.plan, feature_code)
}
}
impl<S> FromRequestParts<S> for AiAccess
where
S: Send + Sync,
{
type Rejection = AiAccessError;
fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
async move {
let auth_header = auth_header.ok_or(AiAccessError::MissingToken)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AiAccessError::InvalidToken)?;
let jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set — refusing to start with insecure default");
let token_data = jsonwebtoken::decode::<contracts::auth_middleware::Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),
&jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256),
)
.map_err(|_| AiAccessError::InvalidToken)?;
let user_id = Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AiAccessError::InvalidToken)?;
let role_code = token_data.claims.active_role.clone();
// State is not available via FromRequestParts, so we cannot load
// the subscription here. Use the Layer middleware below for full checks.
Err(AiAccessError::IncompleteContext)
}
}
}
#[derive(Debug)]
pub enum AiAccessError {
MissingToken,
InvalidToken,
IncompleteContext,
Plan(plans::PlanError),
}
impl From<plans::PlanError> for AiAccessError {
fn from(e: plans::PlanError) -> Self {
AiAccessError::Plan(e)
}
}
impl IntoResponse for AiAccessError {
fn into_response(self) -> Response {
match self {
AiAccessError::Plan(p) => (p.status_code(), axum::Json(p.error_body())).into_response(),
AiAccessError::MissingToken => (
StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({
"error": "Authorization header required",
"code": "MISSING_TOKEN"
})),
)
.into_response(),
AiAccessError::InvalidToken => (
StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({
"error": "Token is invalid or expired",
"code": "INVALID_TOKEN"
})),
)
.into_response(),
AiAccessError::IncompleteContext => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({
"error": "AI access extractor requires middleware context",
"code": "INCOMPLETE_CONTEXT"
})),
)
.into_response(),
}
}
}
/// Middleware that ensures a free subscription exists and validates the user's
/// AI access. Applies to all routes under `/api/ai/*`. Non-customer roles that
/// have no explicit subscription will automatically get a Free plan.
pub async fn ai_access_middleware(
State(_state): State<()>,
auth: AuthUser,
request: Request,
next: Next,
) -> Result<Response, AiAccessError> {
let role_code = auth.claims.active_role.clone();
// State is not available directly in axum middleware; access the pool
// via the request extensions where AppState was installed by `with_state`.
let state = request
.extensions()
.get::<AppState>()
.cloned()
.ok_or_else(|| {
tracing::error!("AppState not found in request extensions");
AiAccessError::IncompleteContext
})?;
let (_sub, _plan) = plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&role_code),
)
.await?;
// TODO: if stricter enforcement is required, reject here instead of
// only inside individual handlers. For now we allow the request through and
// let handlers charge per-feature.
Ok(next.run(request).await)
}

7
apps/users/src/ai/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod credits;
pub mod litellm;
pub mod middleware;
pub mod model_router;
pub mod orchestrator;
pub mod plans;
pub mod usage;

View file

@ -0,0 +1,86 @@
use db::models::ai::{AiFeatureCost, AiPlan};
use serde::{Deserialize, Serialize};
/// Model aliases that may be resolved by the AI gateway. Only two real models
/// are supported by the platform.
pub const ASKASH_FAST: &str = "askash-fast";
pub const ASKASH_MAIN: &str = "askash-main";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModelTier {
Fast,
Main,
}
impl ModelTier {
pub fn alias(&self) -> &'static str {
match self {
ModelTier::Fast => ASKASH_FAST,
ModelTier::Main => ASKASH_MAIN,
}
}
}
impl std::str::FromStr for ModelTier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"askash-fast" | "fast" => Ok(ModelTier::Fast),
"askash-main" | "main" => Ok(ModelTier::Main),
_ => Err(format!("unknown model tier: {}", s)),
}
}
}
/// Pick the right model for a feature considering user preference and plan.
/// Falls back to the feature's default model if the requested one is not
/// allowed by the plan.
pub fn resolve_model(
feature: &AiFeatureCost,
plan: &AiPlan,
requested: Option<&str>,
) -> Result<String, super::plans::PlanError> {
let preferred = requested
.and_then(|m| m.parse::<ModelTier>().ok())
.map(|t| t.alias().to_string())
.unwrap_or_else(|| feature.default_model.clone());
if super::plans::is_model_allowed(plan, &preferred) {
Ok(preferred)
} else if super::plans::is_model_allowed(plan, ASKASH_FAST) {
Ok(ASKASH_FAST.to_string())
} else {
Err(super::plans::PlanError::ModelNotAllowed {
model: preferred,
})
}
}
/// Map a feature code to a suggested model tier for cases where the caller
/// wants to force higher quality (e.g., long-form generation).
pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier {
match feature_code {
"jd_generate"
| "jd_improve"
| "cover_letter_generate"
| "auto_apply_execute"
| "admin_support_reply" => ModelTier::Main,
_ => ModelTier::Fast,
}
}
/// Convenience resolver that selects the best model alias for a feature,
/// preferring Main for complex features unless explicitly overridden.
pub fn resolve_best_model(
feature: &AiFeatureCost,
plan: &AiPlan,
force_main: bool,
) -> Result<String, super::plans::PlanError> {
let requested = if force_main {
Some(ASKASH_MAIN)
} else {
Some(preferred_tier_for_feature(&feature.feature_code).alias())
};
resolve_model(feature, plan, requested)
}

View file

@ -0,0 +1,303 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use contracts::auth_middleware::AuthUser;
use sqlx::PgPool;
use uuid::Uuid;
use crate::ai::{credits, litellm, model_router, plans, usage};
use db::models::ai::AiFeatureCost;
use crate::AppState;
#[derive(Debug, thiserror::Error)]
pub enum AiCallError {
#[error("Plan error: {0}")]
Plan(#[from] plans::PlanError),
#[error("Credit error: {0}")]
Credit(#[from] credits::CreditError),
#[error("LiteLLM error: {0}")]
LiteLlm(#[from] litellm::LiteLlmError),
}
impl AiCallError {
pub fn status_code(&self) -> StatusCode {
match self {
AiCallError::Plan(p) => p.status_code(),
AiCallError::Credit(c) => c.status_code(),
AiCallError::LiteLlm(l) => l.status_code(),
}
}
pub fn error_body(&self) -> serde_json::Value {
match self {
AiCallError::Plan(p) => p.error_body(),
AiCallError::Credit(c) => c.error_body(),
AiCallError::LiteLlm(l) => l.error_body(),
}
}
}
impl IntoResponse for AiCallError {
fn into_response(self) -> Response {
(
self.status_code(),
axum::Json(self.error_body()),
)
.into_response()
}
}
pub struct AiCallResult {
pub text: String,
pub model_alias: String,
pub credits_charged: i32,
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
pub request_id: Option<String>,
pub remaining_credits: i32,
pub remaining_daily_actions: i32,
}
/// One-shot AI completion with full plan/credit enforcement and usage logging.
/// This is the preferred entry point for AI features in handler code.
pub async fn call_feature(
state: &AppState,
auth: &AuthUser,
feature_code: &str,
system_prompt: Option<&str>,
user_message: &str,
requested_model: Option<&str>,
max_tokens: Option<i32>,
) -> Result<AiCallResult, AiCallError> {
let role_code = auth.claims.active_role.clone();
// Ensure subscription and plan permissions.
let (sub, plan) = plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&role_code),
)
.await?;
plans::require_feature(&plan, feature_code)?;
let feature_cost = credits::get_feature_cost(&state.pool, feature_code).await?;
let model_alias = model_router::resolve_model(
&feature_cost,
&plan,
requested_model,
)?;
// Pre-check credits before calling the model.
if credits::remaining_credits(&sub) < feature_cost.credit_cost {
return Err(credits::CreditError::InsufficientCredits.into());
}
let client = litellm::LiteLlmClient::new()?;
let (text, usage, request_id) = client
.chat_completion_text(
&model_alias,
system_prompt,
user_message,
max_tokens,
)
.await?;
// Charge credits now that the model call succeeded.
credits::charge_feature(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
Some(&model_alias),
)
.await?;
// Log actual token usage if LiteLLM returned it.
usage::log_usage(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
&model_alias,
feature_cost.credit_cost,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
"success",
request_id.as_deref(),
None,
)
.await;
let remaining_credits = credits::remaining_credits(&sub) - feature_cost.credit_cost;
let remaining_daily_actions = credits::remaining_daily_actions(&sub, &plan) - 1;
Ok(AiCallResult {
text,
model_alias,
credits_charged: feature_cost.credit_cost,
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
request_id,
remaining_credits,
remaining_daily_actions: remaining_daily_actions.max(0),
})
}
/// Variant for callers that already hold the subscription and plan.
pub async fn call_feature_with_plan(
state: &AppState,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
system_prompt: Option<&str>,
user_message: &str,
requested_model: Option<&str>,
max_tokens: Option<i32>,
) -> Result<AiCallResult, AiCallError> {
let (sub, plan) = plans::require_active_subscription(
&state.pool,
user_id,
role_code,
)
.await?;
plans::require_feature(&plan, feature_code)?;
let feature_cost = credits::get_feature_cost(&state.pool, feature_code).await?;
let model_alias = model_router::resolve_model(
&feature_cost,
&plan,
requested_model,
)?;
if credits::remaining_credits(&sub) < feature_cost.credit_cost {
return Err(credits::CreditError::InsufficientCredits.into());
}
let client = litellm::LiteLlmClient::new()?;
let (text, usage, request_id) = client
.chat_completion_text(
&model_alias,
system_prompt,
user_message,
max_tokens,
)
.await?;
credits::charge_feature(
&state.pool,
user_id,
role_code,
feature_code,
Some(&model_alias),
)
.await?;
usage::log_usage(
&state.pool,
user_id,
role_code,
feature_code,
&model_alias,
feature_cost.credit_cost,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
"success",
request_id.as_deref(),
None,
)
.await;
let remaining_credits = credits::remaining_credits(&sub) - feature_cost.credit_cost;
let remaining_daily_actions = credits::remaining_daily_actions(&sub, &plan) - 1;
Ok(AiCallResult {
text,
model_alias,
credits_charged: feature_cost.credit_cost,
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
request_id,
remaining_credits,
remaining_daily_actions: remaining_daily_actions.max(0),
})
}
/// Validate that a user can use a feature. Returns the resolved model and
/// cost without charging. Useful for streaming pre-flights.
pub async fn check_feature_access(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
requested_model: Option<&str>,
) -> Result<(AiFeatureCost, String), AiCallError> {
let (_sub, plan) = plans::ensure_free_subscription(pool, user_id, role_code).await?;
plans::require_feature(&plan, feature_code)?;
let feature_cost = credits::get_feature_cost(pool, feature_code).await?;
let model_alias = model_router::resolve_model(
&feature_cost,
&plan,
requested_model,
)?;
Ok((feature_cost, model_alias))
}
/// Helper used by handlers to respond with a JSON body that includes remaining
/// credits for the frontend.
pub fn success_json(
result: &AiCallResult,
extra: serde_json::Value,
) -> serde_json::Value {
let mut base = serde_json::json!({
"message": result.text,
"model": result.model_alias,
"credits_charged": result.credits_charged,
"remaining_credits": result.remaining_credits,
"remaining_daily_actions": result.remaining_daily_actions,
"request_id": result.request_id,
});
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
base[k] = v.clone();
}
} else {
base["extra"] = extra;
}
base
}
/// Log a failed AI call without charging credits.
pub async fn log_failed_call(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
model_alias: &str,
error_message: &str,
) {
usage::log_usage(
pool,
user_id,
role_code,
feature_code,
model_alias,
0,
None,
None,
None,
"error",
None,
Some(error_message),
)
.await;
}

183
apps/users/src/ai/plans.rs Normal file
View file

@ -0,0 +1,183 @@
use chrono::{DateTime, Datelike, Duration, Utc};
use db::models::ai::{AiPlan, AiPlanRepository, UserAiSubscription, UserAiSubscriptionRepository};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
#[error("Database error: {0}")]
Db(#[from] sqlx::Error),
#[error("No active subscription")]
NoSubscription,
#[error("Subscription expired")]
SubscriptionExpired,
#[error("Feature '{feature}' not allowed by plan")]
FeatureNotAllowed { feature: String },
#[error("Model '{model}' not allowed by plan")]
ModelNotAllowed { model: String },
#[error("Customer role has no AI access")]
CustomerRoleForbidden,
}
impl PlanError {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
PlanError::NoSubscription => StatusCode::PAYMENT_REQUIRED,
PlanError::SubscriptionExpired => StatusCode::PAYMENT_REQUIRED,
PlanError::FeatureNotAllowed { .. } => StatusCode::FORBIDDEN,
PlanError::ModelNotAllowed { .. } => StatusCode::FORBIDDEN,
PlanError::CustomerRoleForbidden => StatusCode::FORBIDDEN,
PlanError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn error_body(&self) -> serde_json::Value {
serde_json::json!({
"error": self.to_string(),
"code": match self {
PlanError::NoSubscription => "NO_AI_SUBSCRIPTION",
PlanError::SubscriptionExpired => "AI_SUBSCRIPTION_EXPIRED",
PlanError::FeatureNotAllowed { .. } => "AI_FEATURE_NOT_ALLOWED",
PlanError::ModelNotAllowed { .. } => "AI_MODEL_NOT_ALLOWED",
PlanError::CustomerRoleForbidden => "AI_CUSTOMER_FORBIDDEN",
PlanError::Db(_) => "INTERNAL_ERROR",
}
})
}
}
const CUSTOMER_ROLE: &str = "customer";
/// Resolves an active subscription, ensuring the user is not a customer and the
/// current billing period is valid. This should be called before any AI action.
pub async fn require_active_subscription(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
) -> Result<(UserAiSubscription, AiPlan), PlanError> {
if role_code.map(|r| r.eq_ignore_ascii_case(CUSTOMER_ROLE)).unwrap_or(false) {
return Err(PlanError::CustomerRoleForbidden);
}
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)
.await?
.ok_or(PlanError::NoSubscription)?;
let now = Utc::now();
if now < sub.current_period_start || now >= sub.current_period_end {
return Err(PlanError::SubscriptionExpired);
}
let plan = AiPlanRepository::get_by_id(pool, sub.plan_id)
.await?
.ok_or_else(|| {
tracing::error!("Subscription references unknown plan_id={}", sub.plan_id);
PlanError::NoSubscription
})?;
Ok((sub, plan))
}
pub fn is_feature_allowed(plan: &AiPlan, feature_code: &str) -> bool {
plan.allowed_features
.as_array()
.map(|arr| arr.iter().any(|v| v.as_str() == Some(feature_code)))
.unwrap_or(false)
}
pub fn is_model_allowed(plan: &AiPlan, model_alias: &str) -> bool {
plan.allowed_models
.as_array()
.map(|arr| arr.iter().any(|v| v.as_str() == Some(model_alias)))
.unwrap_or(false)
}
pub fn require_feature(plan: &AiPlan, feature_code: &str) -> Result<(), PlanError> {
if is_feature_allowed(plan, feature_code) {
Ok(())
} else {
Err(PlanError::FeatureNotAllowed {
feature: feature_code.to_string(),
})
}
}
pub fn require_model(plan: &AiPlan, model_alias: &str) -> Result<(), PlanError> {
if is_model_allowed(plan, model_alias) {
Ok(())
} else {
Err(PlanError::ModelNotAllowed {
model: model_alias.to_string(),
})
}
}
/// Idempotently create a Free subscription for a user if none exists.
pub async fn ensure_free_subscription(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
) -> Result<(UserAiSubscription, AiPlan), PlanError> {
if let Some(existing) = UserAiSubscriptionRepository::get_by_user_id(pool, user_id).await? {
let plan = AiPlanRepository::get_by_id(pool, existing.plan_id)
.await?
.unwrap_or_else(|| {
tracing::warn!("Could not resolve plan for subscription {}", existing.id);
// Return a synthetic plan to avoid panic; real deployments should fix data.
AiPlan {
id: existing.plan_id,
code: "unknown".into(),
name: "Unknown".into(),
monthly_credits: 0,
daily_action_limit: 0,
allowed_models: serde_json::Value::Array(vec![]),
allowed_features: serde_json::Value::Array(vec![]),
is_active: false,
created_at: existing.created_at,
updated_at: existing.updated_at,
}
});
return Ok((existing, plan));
}
let plan = AiPlanRepository::get_by_code(pool, "free")
.await?
.ok_or(PlanError::NoSubscription)?;
let now = Utc::now();
let start = now.with_day(1).unwrap_or(now).with_time(chrono::NaiveTime::MIN).unwrap();
let end = (start + Duration::days(32))
.with_day(1)
.unwrap_or(start + Duration::days(32))
.with_time(chrono::NaiveTime::MIN)
.unwrap();
let sub = UserAiSubscriptionRepository::create(
pool,
user_id,
plan.id,
role_code,
plan.monthly_credits,
start,
end,
)
.await?;
Ok((sub, plan))
}
/// Recompute the start/end of the current monthly period.
pub fn current_monthly_period(now: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let start = now
.with_day(1)
.unwrap_or(now)
.with_time(chrono::NaiveTime::MIN)
.unwrap();
let end = (start + Duration::days(32))
.with_day(1)
.unwrap_or(start + Duration::days(32))
.with_time(chrono::NaiveTime::MIN)
.unwrap();
(start, end)
}

View file

@ -0,0 +1,40 @@
use db::models::ai::AiUsageLogRepository;
use sqlx::PgPool;
use uuid::Uuid;
/// Log an AI usage event. Errors are logged but not propagated, because usage
/// logging should never break the user-facing response.
#[allow(clippy::too_many_arguments)]
pub async fn log_usage(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
model_alias: &str,
credits_charged: i32,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
status: &str,
request_id: Option<&str>,
error_message: Option<&str>,
) {
if let Err(e) = AiUsageLogRepository::create(
pool,
user_id,
role_code,
feature_code,
model_alias,
credits_charged,
input_tokens,
output_tokens,
total_tokens,
status,
request_id,
error_message,
)
.await
{
tracing::error!("Failed to log AI usage for user {}: {}", user_id, e);
}
}

View file

@ -0,0 +1,527 @@
use crate::ai::{credits, litellm, model_router, plans, usage};
use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::ai::{
AiCreditTransactionRepository, AiFeatureCost, AiFeatureCostRepository, AiPlanRepository,
AiUsageLogRepository, UserAiSubscriptionRepository,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct AdminAiPrompt {
context: String,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AbuseCheckBody {
text: String,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdatePlanBody {
user_id: Uuid,
plan_code: String,
}
#[derive(Debug, Deserialize)]
struct AddCreditsBody {
user_id: Uuid,
credits: i32,
#[serde(default = "default_credit_source")]
source: String,
description: Option<String>,
}
fn default_credit_source() -> String {
"admin".to_string()
}
#[derive(Debug, Deserialize)]
struct ListLogsQuery {
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
fn default_limit() -> i64 {
50
}
pub fn admin_ai_router() -> Router<AppState> {
Router::new()
.route("/support/reply", post(admin_support_reply))
.route("/tickets/summary", post(admin_ticket_summary))
.route("/verification/summary", post(admin_verification_summary))
.route("/abuse/check", post(admin_abuse_check))
.route("/users/{user_id}/plan", post(update_user_plan))
.route("/users/{user_id}/credits", post(add_user_credits))
.route("/users/{user_id}/usage", get(user_usage_logs))
.route("/users/{user_id}/transactions", get(user_credit_transactions))
.route("/plans", get(list_plans))
.route("/features", get(list_features))
}
async fn admin_support_reply(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AdminAiPrompt>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let prompt = format!(
"You are a Nxtgauge support assistant. Draft a helpful, professional reply to the following support context. \
Keep it concise and empathetic.\n\nContext: {}\n\nReply:",
body.context
);
admin_ai_call(
&state,
&auth,
"admin_support_reply",
&prompt,
body.model.as_deref(),
)
.await
}
async fn admin_ticket_summary(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AdminAiPrompt>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let prompt = format!(
"Summarize the following support tickets into key themes, open issues, and recommended next actions. \
Use bullet points.\n\nContext: {}\n\nSummary:",
body.context
);
admin_ai_call(
&state,
&auth,
"admin_ticket_summary",
&prompt,
body.model.as_deref(),
)
.await
}
async fn admin_verification_summary(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AdminAiPrompt>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let prompt = format!(
"You are a Nxtgauge verification reviewer. Given the following verification notes, \
produce a short summary and a recommend action (approve, reject, request more info).\n\nContext: {}\n\nSummary and recommendation:",
body.context
);
admin_ai_call(
&state,
&auth,
"admin_verification_summary",
&prompt,
body.model.as_deref(),
)
.await
}
async fn admin_abuse_check(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AbuseCheckBody>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let prompt = format!(
"You are a content moderation assistant. Review the following text and respond with ONLY one word: \
SAFE or FLAGGED. Then add a one-sentence reason.\n\nText: {}\n\nVerdict:",
body.text
);
admin_ai_call(
&state,
&auth,
"abuse_check",
&prompt,
body.model.as_deref(),
)
.await
}
async fn admin_ai_call(
state: &AppState,
auth: &AuthUser,
feature_code: &str,
prompt: &str,
requested_model: Option<&str>,
) -> axum::http::Response<axum::body::Body> {
let role_code = auth.claims.active_role.clone();
let (sub, plan) = match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&role_code),
)
.await
{
Ok(sp) => sp,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
if !plans::is_feature_allowed(&plan, feature_code) {
let e = plans::PlanError::FeatureNotAllowed {
feature: feature_code.to_string(),
};
return (e.status_code(), Json(e.error_body())).into_response();
}
let feature_cost = match credits::get_feature_cost(&state.pool, feature_code).await {
Ok(c) => c,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
let model_alias = match model_router::resolve_model(&feature_cost, &plan, requested_model) {
Ok(m) => m,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
if credits::remaining_credits(&sub) < feature_cost.credit_cost {
let e = credits::CreditError::InsufficientCredits;
return (e.status_code(), Json(e.error_body())).into_response();
}
let client = match litellm::LiteLlmClient::new() {
Ok(c) => c,
Err(e) => {
tracing::error!("LiteLLM client creation failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "AI service unavailable" })),
)
.into_response();
}
};
let (text, usage, request_id) = match client
.chat_completion_text(&model_alias, None, prompt, None)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("LiteLLM call failed: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "AI model call failed" })),
)
.into_response();
}
};
if let Err(e) = credits::charge_feature(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
Some(&model_alias),
)
.await
{
tracing::error!(
"Failed to charge admin AI feature {} for user {}: {}",
feature_code,
auth.user_id,
e
);
}
usage::log_usage(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
&model_alias,
feature_cost.credit_cost,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
"success",
request_id.as_deref(),
None,
)
.await;
let remaining_credits = credits::remaining_credits(&sub) - feature_cost.credit_cost;
let remaining_daily = credits::remaining_daily_actions(&sub, &plan) - 1;
(
StatusCode::OK,
Json(serde_json::json!({
"message": text,
"model": model_alias,
"credits_charged": feature_cost.credit_cost,
"remaining_credits": remaining_credits,
"remaining_daily_actions": remaining_daily.max(0),
"request_id": request_id,
})),
)
.into_response()
}
async fn update_user_plan(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<UpdatePlanBody>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let plan = match AiPlanRepository::get_by_code(&state.pool, &body.plan_code).await {
Ok(Some(p)) => p,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Plan not found" })),
)
.into_response();
}
Err(e) => {
tracing::error!("Database error fetching plan: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
let (period_start, period_end) = plans::current_monthly_period(chrono::Utc::now());
let sub = match UserAiSubscriptionRepository::update_plan(
&state.pool,
body.user_id,
plan.id,
plan.monthly_credits,
period_start,
period_end,
)
.await
{
Ok(s) => s,
Err(e) => {
tracing::error!("Failed to update plan for user {}: {}", body.user_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update plan" })),
)
.into_response();
}
};
(
StatusCode::OK,
Json(serde_json::json!({
"user_id": body.user_id,
"plan_code": plan.code,
"plan_name": plan.name,
"monthly_credits_total": sub.monthly_credits_total,
"current_period_start": sub.current_period_start,
"current_period_end": sub.current_period_end,
})),
)
.into_response()
}
async fn add_user_credits(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AddCreditsBody>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
if body.credits <= 0 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Credits must be positive" })),
)
.into_response();
}
let sub = match UserAiSubscriptionRepository::get_by_user_id(&state.pool, body.user_id).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "User has no AI subscription" })),
)
.into_response();
}
Err(e) => {
tracing::error!("Database error fetching subscription: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
if let Err(e) =
UserAiSubscriptionRepository::add_purchased_credits(&state.pool, body.user_id, body.credits)
.await
{
tracing::error!("Failed to add credits for user {}: {}", body.user_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to add credits" })),
)
.into_response();
}
let balance_after = credits::remaining_credits(&sub) + body.credits;
if let Err(e) = AiCreditTransactionRepository::create(
&state.pool,
body.user_id,
"credit",
&body.source,
body.credits,
balance_after,
None,
body.description.as_deref(),
)
.await
{
tracing::error!("Failed to log credit transaction for user {}: {}", body.user_id, e);
}
(
StatusCode::OK,
Json(serde_json::json!({
"user_id": body.user_id,
"credits_added": body.credits,
"balance_after": balance_after,
})),
)
.into_response()
}
async fn user_usage_logs(
State(state): State<AppState>,
auth: AuthUser,
Path(user_id): Path<Uuid>,
Query(q): Query<ListLogsQuery>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match AiUsageLogRepository::list_by_user(&state.pool, user_id, q.limit, q.offset).await {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "logs": rows }))).into_response(),
Err(e) => {
tracing::error!("Failed to fetch usage logs for {}: {}", user_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn user_credit_transactions(
State(state): State<AppState>,
auth: AuthUser,
Path(user_id): Path<Uuid>,
Query(q): Query<ListLogsQuery>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match AiCreditTransactionRepository::list_by_user(&state.pool, user_id, q.limit, q.offset)
.await
{
Ok(rows) => (
StatusCode::OK,
Json(serde_json::json!({ "transactions": rows })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch credit transactions for {}: {}", user_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn list_plans(State(state): State<AppState>, auth: AuthUser) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match AiPlanRepository::list_active(&state.pool).await {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "plans": rows }))).into_response(),
Err(e) => {
tracing::error!("Failed to list plans: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn list_features(State(state): State<AppState>, auth: AuthUser) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match AiFeatureCostRepository::list_active(&state.pool).await {
Ok(rows) => (
StatusCode::OK,
Json(serde_json::json!({ "features": rows })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to list features: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}

View file

@ -1,3 +1,4 @@
use crate::ai::{credits, litellm, model_router, orchestrator, plans, usage};
use crate::AppState;
use axum::{
extract::State,
@ -9,6 +10,7 @@ use axum::{
use cache::ai as ai_cache;
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
@ -359,7 +361,7 @@ async fn ai_chat_message(
let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string());
let default_conversation = Uuid::new_v4().to_string();
let conversation_id = body.conversation_id.unwrap_or_else(|| default_conversation);
let conversation_id = body.conversation_id.clone().unwrap_or_else(|| default_conversation);
// ── Phase 1: Strict keyword fast-path (skips Ollama when unambiguous) ─────
let (intent, confidence) = match classify_strict_keywords(&body.message) {
@ -367,7 +369,7 @@ async fn ai_chat_message(
None => classify_intent(&body.message, &ollama_base, &model).await,
};
let response_text = match intent.as_str() {
let (response_text, ollama_used) = match intent.as_str() {
"help_search" => {
let q = body.message.to_lowercase();
let rows = sqlx::query_as::<_, KbArticleRow>(
@ -393,7 +395,9 @@ async fn ai_chat_message(
.iter()
.map(|a| {
format!(
"- **{}** ({})\n {}\n /help-center/article/{}",
"- **{}** ({})
{}
/help-center/article/{}",
a.title,
a.category_name,
a.summary.as_deref().unwrap_or(""),
@ -401,18 +405,22 @@ async fn ai_chat_message(
)
})
.collect();
format!(
"I found {} help article(s) for you:\n\n{}\n\nIs any of these what you were looking for?",
articles.len(),
links.join("\n\n")
(
format!(
"I found {} help article(s) for you:\n\n{}\n\nIs any of these what you were looking for?",
articles.len(),
links.join("\n\n")
),
false,
)
}
_ => {
_ => (
"I couldn't find any help articles matching your question. \
If you need further assistance, I can help you create a support ticket instead. \
Just describe your issue and I'll guide you through the ticket creation process."
.to_string()
}
.to_string(),
false,
),
}
}
"job_description_generation" => {
@ -425,40 +433,43 @@ async fn ai_chat_message(
Job Description:",
body.message
);
match call_ollama(&state, &model, &jd_prompt).await {
Ok(r) => r,
Err(e) => {
tracing::error!("Ollama JD generation error: {}", e);
"I'm having trouble generating a job description right now. Please try again.".to_string()
}
}
ai_chat_generate(
&state,
&body,
"jd_generate",
&jd_prompt,
body.model.as_deref(),
)
.await
}
"ticket_creation" => {
let system_prompt = "You are a support ticket assistant. Help users create clear, actionable support tickets. \
Ask for: subject, description of issue, category, priority if not provided. \
Summarize the ticket in a structured way.";
let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message);
match call_ollama(&state, &model, &full_prompt).await {
Ok(r) => r,
Err(e) => {
tracing::error!("Ollama error: {}", e);
"I'm having trouble processing your request right now. Please try again or contact support.".to_string()
}
}
ai_chat_generate(
&state,
&body,
"platform_guidance",
&full_prompt,
body.model.as_deref(),
)
.await
}
"form_filling" => {
let system_prompt = "You are a form filling assistant. Help users fill out forms by extracting relevant information \
from their message. Extract key:value pairs when possible.";
let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message);
match call_ollama(&state, &model, &full_prompt).await {
Ok(r) => r,
Err(e) => {
tracing::error!("Ollama error: {}", e);
"I'm having trouble processing your request right now. Please try again or contact support.".to_string()
}
}
ai_chat_generate(
&state,
&body,
"form_fill",
&full_prompt,
body.model.as_deref(),
)
.await
}
"unknown" => {
"unknown" => (
"I'm not sure I understand your request. I can help you with:\n\n\
- Creating support tickets\n\
- Searching help articles\n\
@ -467,19 +478,21 @@ async fn ai_chat_message(
- Improving your resume\n\
- Applying to jobs\n\
- Requesting to view lead contacts\n\n\
Could you please rephrase your request?".to_string()
}
Could you please rephrase your request?".to_string(),
false,
),
_ => {
let system_prompt = "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \
If the user needs support, guide them to create a ticket.";
let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message);
match call_ollama(&state, &model, &full_prompt).await {
Ok(r) => r,
Err(e) => {
tracing::error!("Ollama error: {}", e);
"I'm having trouble processing your request right now. Please try again or contact support.".to_string()
}
}
ai_chat_generate(
&state,
&body,
"help_answer",
&full_prompt,
body.model.as_deref(),
)
.await
}
};
@ -495,6 +508,152 @@ async fn ai_chat_message(
.into_response()
}
/// Helper for legacy /chat/message that doesn't have an AuthUser. It derives a
/// synthetic user id from body.user_id or treats the request as a non-billed
/// anonymous interaction. In practice this endpoint should be deprecated in
/// favor of /chat/ask which is fully authenticated and plan-aware.
async fn ai_chat_generate(
state: &AppState,
body: &OllamaChatRequest,
feature_code: &str,
prompt: &str,
requested_model: Option<&str>,
) -> (String, bool) {
let user_id = body
.user_id
.as_deref()
.and_then(|u| Uuid::parse_str(u).ok())
.unwrap_or_else(Uuid::nil);
if user_id == Uuid::nil() {
// Anonymous fallback: use direct LiteLLM without billing.
let client = match litellm::LiteLlmClient::new() {
Ok(c) => c,
Err(e) => {
tracing::error!("LiteLLM client creation failed: {}", e);
return ("AI service unavailable.".to_string(), false);
}
};
return match client
.chat_completion_text("askash-fast", None, prompt, None)
.await
{
Ok((text, _, _)) => (text, true),
Err(e) => {
tracing::error!("LiteLLM fallback failed: {}", e);
("I'm having trouble processing your request right now.".to_string(), false)
}
};
}
// Look up the user's active role from the database; default to the feature's
// target role if no role is found.
let role_code: Option<String> = sqlx::query_scalar(
"SELECT active_role FROM users WHERE id = $1"
)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let role_code_ref = role_code.as_deref();
let (sub, plan) = match plans::ensure_free_subscription(
&state.pool,
user_id,
role_code_ref,
)
.await
{
Ok(sp) => sp,
Err(e) => {
tracing::warn!("AI plan check failed for nil-auth user {}: {}", user_id, e);
return (e.to_string(), false);
}
};
if !plans::is_feature_allowed(&plan, feature_code) {
return (
plans::PlanError::FeatureNotAllowed {
feature: feature_code.to_string(),
}
.to_string(),
false,
);
}
let feature_cost = match credits::get_feature_cost(&state.pool, feature_code).await {
Ok(c) => c,
Err(e) => return (e.to_string(), false),
};
let model_alias = match model_router::resolve_model(&feature_cost,
&plan,
requested_model,
) {
Ok(m) => m,
Err(e) => return (e.to_string(), false),
};
if credits::remaining_credits(&sub) < feature_cost.credit_cost {
return (credits::CreditError::InsufficientCredits.to_string(), false);
}
let client = match litellm::LiteLlmClient::new() {
Ok(c) => c,
Err(e) => {
tracing::error!("LiteLLM client creation failed: {}", e);
return ("AI service unavailable.".to_string(), false);
}
};
let (text, usage, request_id) = match client
.chat_completion_text(&model_alias,
None,
prompt,
None,
)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("LiteLLM call failed: {}", e);
return ("I'm having trouble processing your request right now.".to_string(), false);
}
};
if let Err(e) = credits::charge_feature(
&state.pool,
user_id,
role_code_ref,
feature_code,
Some(&model_alias),
)
.await
{
tracing::error!("Failed to charge AI feature {} for user {}: {}", feature_code, user_id, e);
}
usage::log_usage(
&state.pool,
user_id,
role_code_ref,
feature_code,
&model_alias,
feature_cost.credit_cost,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
"success",
request_id.as_deref(),
None,
)
.await;
(text, true)
}
async fn ai_create_ticket(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
@ -785,6 +944,44 @@ struct GenerateFieldResponse {
remaining_today: i32,
daily_limit: i32,
has_ai_pack: bool,
#[serde(skip_serializing_if = "Option::is_none")]
credits_charged: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
remaining_credits: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
fn from_orchestrator_result(
generated_text: String,
r: &orchestrator::AiCallResult,
daily_limit: i32,
) -> GenerateFieldResponse {
GenerateFieldResponse {
generated_text,
remaining_today: r.remaining_daily_actions,
daily_limit,
has_ai_pack: r.credits_charged > 0,
credits_charged: Some(r.credits_charged),
remaining_credits: Some(r.remaining_credits),
model: Some(r.model_alias.clone()),
}
}
fn fallback_response(
generated_text: String,
remaining_today: i32,
daily_limit: i32,
) -> GenerateFieldResponse {
GenerateFieldResponse {
generated_text,
remaining_today,
daily_limit,
has_ai_pack: false,
credits_charged: None,
remaining_credits: None,
model: None,
}
}
async fn ai_generate_job_field(
@ -834,8 +1031,7 @@ 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 feature_code = "jd_generate";
let field_prompt = match body.field.as_str() {
"title" => format!(
@ -861,23 +1057,28 @@ async fn ai_generate_job_field(
}
};
let generated = match call_ollama_inline(&ollama_base, &model, &field_prompt).await {
Ok(r) => r.trim().to_string(),
Err(e) => {
tracing::error!("Ollama job field generation error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response();
match orchestrator::call_feature(
&state,
&auth,
feature_code,
None,
&field_prompt,
None,
None,
)
.await
{
Ok(mut r) => {
let daily_limit = r.remaining_daily_actions + 1;
let text = std::mem::take(&mut r.text);
(
StatusCode::OK,
Json(from_orchestrator_result(text, &r, daily_limit)),
)
.into_response()
}
};
(
StatusCode::OK,
Json(GenerateFieldResponse {
generated_text: generated,
remaining_today: limit - used,
daily_limit: limit,
has_ai_pack: has_pack,
}),
).into_response()
Err(e) => e.into_response(),
}
}
// ── Cover Letter Generation (Job Seekers) ──────────────────────────────────────
@ -974,23 +1175,28 @@ async fn ai_generate_cover_letter(
full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, notes, job_title, job_desc, location
);
let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await {
Ok(r) => r.trim().to_string(),
Err(e) => {
tracing::error!("Ollama cover letter generation error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response();
match orchestrator::call_feature(
&state,
&auth,
"cover_letter_generate",
None,
&prompt,
None,
None,
)
.await
{
Ok(mut r) => {
let daily_limit = r.remaining_daily_actions + 1;
let text = std::mem::take(&mut r.text);
(
StatusCode::OK,
Json(from_orchestrator_result(text, &r, daily_limit)),
)
.into_response()
}
};
(
StatusCode::OK,
Json(GenerateFieldResponse {
generated_text: generated,
remaining_today: limit - used,
daily_limit: limit,
has_ai_pack: has_pack,
}),
).into_response()
Err(e) => e.into_response(),
}
}
// ── Tailor Resume (Job Seekers) ─────────────────────────────────────────────────
@ -1086,23 +1292,28 @@ async fn ai_tailor_resume(
full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, existing_resume, job_title, job_desc
);
let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await {
Ok(r) => r.trim().to_string(),
Err(e) => {
tracing::error!("Ollama resume tailoring error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response();
match orchestrator::call_feature(
&state,
&auth,
"form_fill",
None,
&prompt,
None,
None,
)
.await
{
Ok(mut r) => {
let daily_limit = r.remaining_daily_actions + 1;
let text = std::mem::take(&mut r.text);
(
StatusCode::OK,
Json(from_orchestrator_result(text, &r, daily_limit)),
)
.into_response()
}
};
(
StatusCode::OK,
Json(GenerateFieldResponse {
generated_text: generated,
remaining_today: limit - used,
daily_limit: limit,
has_ai_pack: has_pack,
}),
).into_response()
Err(e) => e.into_response(),
}
}
// ── Auto Apply (Job Seekers) ───────────────────────────────────────────────────
@ -1190,10 +1401,39 @@ async fn ai_auto_apply(
let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string());
let skills_str = skills.join(", ");
// Pre-check credits for all requested applications before generating anything.
let feature_cost = match credits::get_feature_cost(&state.pool, "auto_apply_execute").await {
Ok(c) => c,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
let cost_per_job = feature_cost.credit_cost;
let total_cost = cost_per_job * body.job_ids.len() as i32;
let (sub, plan) = match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.await
{
Ok(sp) => sp,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
if !plans::is_feature_allowed(&plan, "auto_apply_execute") {
let e = plans::PlanError::FeatureNotAllowed { feature: "auto_apply_execute".into() };
return (e.status_code(), Json(e.error_body())).into_response();
}
if credits::remaining_credits(&sub) < total_cost {
let e = credits::CreditError::InsufficientCredits;
return (e.status_code(), Json(e.error_body())).into_response();
}
if credits::remaining_daily_actions(&sub, &plan) < body.job_ids.len() as i32 {
let e = credits::CreditError::DailyActionLimitReached;
return (e.status_code(), Json(e.error_body())).into_response();
}
let mut created = 0;
let mut already = vec![];
let mut failed = vec![];
let mut redis = state.redis.clone();
for job_id in &body.job_ids {
let existing: Option<Uuid> = sqlx::query_scalar(
@ -1235,8 +1475,19 @@ async fn ai_auto_apply(
full_name, experience, skills_str, summary.as_deref().unwrap_or(""), job_title, job_desc
);
let cover_letter = match call_ollama_inline(&ollama_base, &model, &cover_prompt).await {
Ok(r) => r.trim().to_string(),
let cover_letter = match orchestrator::call_feature_with_plan(
&state,
auth.user_id,
Some(&auth.claims.active_role),
"auto_apply_execute",
None,
&cover_prompt,
None,
None,
)
.await
{
Ok(r) => r.text,
Err(_) => "I am excited to apply for this position.".to_string(),
};
@ -1257,7 +1508,6 @@ async fn ai_auto_apply(
Ok(r) => {
if r.rows_affected() > 0 {
created += 1;
let _ = check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await;
} else {
already.push(*job_id);
}
@ -1268,17 +1518,20 @@ async fn ai_auto_apply(
}
}
let new_remaining = remaining - created;
let new_remaining = credits::remaining_credits(&sub) - (cost_per_job * created);
let new_daily = credits::remaining_daily_actions(&sub, &plan) - created;
(
StatusCode::OK,
Json(AutoApplyResponse {
applications_created: created,
already_applied: already,
failed,
remaining_today: new_remaining.max(0),
daily_limit,
}),
Json(serde_json::json!({
"applications_created": created,
"already_applied": already,
"failed": failed,
"remaining_today": new_daily.max(0),
"daily_limit": plan.daily_action_limit,
"credits_charged": cost_per_job * created,
"remaining_credits": new_remaining,
})),
).into_response()
}
@ -1352,20 +1605,42 @@ async fn ai_auto_respond_to_lead(
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ "error": format!("Insufficient balance. You need {} Tracecoins but have {}. Please top up your wallet.", tracecoins_cost, balance) }))).into_response();
}
let today = chrono::Utc::now().date_naive();
let used: Option<i32> = sqlx::query_scalar(
"SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2"
// Ensure the user has a valid AI subscription for the auto-request feature.
match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.bind(profile_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
{
Ok((sub, plan)) => {
if !plans::is_feature_allowed(&plan, "auto_request_execute") {
let e = plans::PlanError::FeatureNotAllowed { feature: "auto_request_execute".into() };
return (e.status_code(), Json(e.error_body())).into_response();
}
if credits::remaining_credits(&sub) < 3 {
let e = credits::CreditError::InsufficientCredits;
return (e.status_code(), Json(e.error_body())).into_response();
}
if credits::remaining_daily_actions(&sub, &plan) < 1 {
let e = credits::CreditError::DailyActionLimitReached;
return (e.status_code(), Json(e.error_body())).into_response();
}
}
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
}
let daily_limit = 10;
if used.unwrap_or(0) >= daily_limit {
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": "Daily AI auto-respond limit reached (10/day)" }))).into_response();
// Charge the feature now that the lead service call is about to happen.
if let Err(e) = credits::charge_feature(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
"auto_request_execute",
None,
)
.await
{
return (e.status_code(), Json(e.error_body())).into_response();
}
let url = format!("{}/api/lead-requests/send-ai", leads_service_url.trim_end_matches('/'));
@ -1393,25 +1668,8 @@ async fn ai_auto_respond_to_lead(
return (StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), Json(serde_json::json!({ "error": body }))).into_response();
}
let _ = sqlx::query(
r#"
INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used)
VALUES ($1, $2, 1)
ON CONFLICT (job_seeker_id, usage_date)
DO UPDATE SET generations_used = job_seeker_ai_usage.generations_used + 1, updated_at = NOW()
"#
)
.bind(profile_id)
.bind(today)
.execute(&state.pool)
.await;
let remaining = daily_limit - used.unwrap_or(0) - 1;
(StatusCode::OK, Json(serde_json::json!({
"success": true,
"remaining_today": remaining.max(0),
"daily_limit": daily_limit,
"message": "AI response sent successfully"
}))).into_response()
}
@ -1488,13 +1746,33 @@ async fn ai_usage_status(
}
};
let remaining = daily_limit - used.unwrap_or(0);
// New plan-aware usage status.
let status = match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.await
{
Ok((sub, plan)) => {
serde_json::json!({
"remaining_today": credits::remaining_daily_actions(&sub, &plan),
"daily_limit": plan.daily_action_limit,
"remaining_credits": credits::remaining_credits(&sub),
"monthly_credits_total": sub.monthly_credits_total,
"monthly_credits_used": sub.monthly_credits_used,
"purchased_credits_total": sub.purchased_credits_total,
"purchased_credits_used": sub.purchased_credits_used,
"plan_code": plan.code,
"plan_name": plan.name,
})
}
Err(e) => {
return (e.status_code(), Json(e.error_body())).into_response();
}
};
(StatusCode::OK, Json(UsageStatusResponse {
remaining_today: remaining.max(0),
daily_limit,
has_ai_pack: has_pack,
})).into_response()
(StatusCode::OK, Json(status)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
@ -1911,6 +2189,8 @@ pub struct AskAshRequest {
pub conversation_id: Option<String>,
/// Optional user id (fallback for unauthenticated context; auth user wins if both present)
pub user_id: Option<Uuid>,
/// Optional model override (askash-fast or askash-main)
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@ -2029,22 +2309,32 @@ async fn ai_chat_ask(
let full_prompt = format!("{system_prompt}\n\n{user_block}\n\nAssistant:");
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 (response_text, ollama_used) =
match ollama_generate_with_timeout(&ollama_base, &model, &full_prompt).await {
Ok(r) if !r.trim().is_empty() => (r.trim().to_string(), true),
Ok(_) => (
local_fallback_response(persona, pillar, &body.message),
false,
),
let (response_text, model_used, credits_charged, remaining_credits, remaining_daily, request_id, ollama_used) =
match orchestrator::call_feature(
&state,
&auth,
"help_answer",
None,
&full_prompt,
body.model.as_deref(),
None,
)
.await
{
Ok(r) => (r.text, Some(r.model_alias), r.credits_charged, r.remaining_credits, r.remaining_daily_actions, r.request_id, true),
Err(e) => {
tracing::warn!("Ollama call failed, using local fallback: {}", e);
// If the error is a plan/credit issue, return it directly instead of fallback.
if matches!(e, orchestrator::AiCallError::Plan(_) | orchestrator::AiCallError::Credit(_)) {
return e.into_response();
}
tracing::warn!("AI call failed, using local fallback: {}", e);
(
local_fallback_response(persona, pillar, &body.message),
None,
0,
0,
0,
None,
false,
)
}
@ -3289,6 +3579,30 @@ pub mod phase3 {
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.
// New plan-aware usage status.
let plan_status = match crate::ai::plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.await
{
Ok((sub, plan)) => serde_json::json!({
"plan_code": plan.code,
"plan_name": plan.name,
"remaining_daily_actions": crate::ai::credits::remaining_daily_actions(&sub, &plan),
"daily_action_limit": plan.daily_action_limit,
"remaining_credits": crate::ai::credits::remaining_credits(&sub),
"monthly_credits_total": sub.monthly_credits_total,
"monthly_credits_used": sub.monthly_credits_used,
"purchased_credits_total": sub.purchased_credits_total,
"purchased_credits_used": sub.purchased_credits_used,
"period_start": sub.current_period_start,
"period_end": sub.current_period_end,
}),
Err(e) => e.error_body(),
};
(
axum::http::StatusCode::OK,
axum::Json(serde_json::json!({
@ -3310,6 +3624,7 @@ pub mod phase3 {
"remaining": (30 - stream_minute).max(0),
},
},
"plan": plan_status,
})),
)
}
@ -3452,6 +3767,10 @@ pub fn ai_router() -> Router<AppState> {
.route("/clear-history", axum::routing::post(phase3::ai_clear_history))
// ── Phase 4: multi-lang, voice, A/B, analytics, model swap, KB+ ───
.merge(crate::handlers::ai_phase4::phase4_router())
.layer(axum::middleware::from_fn_with_state(
(),
crate::ai::middleware::ai_access_middleware,
))
}
#[cfg(test)]
@ -3488,6 +3807,9 @@ mod tests {
remaining_today: 4,
daily_limit: 5,
has_ai_pack: false,
credits_charged: None,
remaining_credits: None,
model: None,
};
let json = serde_json::to_value(&response).unwrap();
assert_eq!(json["generated_text"], "Senior Rust Developer");
@ -3503,6 +3825,9 @@ mod tests {
remaining_today: 15,
daily_limit: 20,
has_ai_pack: true,
credits_charged: None,
remaining_credits: None,
model: None,
};
let json = serde_json::to_value(&response).unwrap();
assert_eq!(json["has_ai_pack"], true);

View file

@ -0,0 +1,407 @@
use crate::ai::{credits, litellm, model_router, plans, usage};
use crate::AppState;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use db::models::ai::{
AiAutoApplyLogRepository, AiAutoApplySettingsRepository, AiAutoRequestLogRepository,
AiAutoRequestSettingsRepository,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Deserialize)]
struct AutoApplySettingsBody {
#[serde(default)]
is_enabled: Option<bool>,
#[serde(default)]
preferred_titles: Option<Value>,
#[serde(default)]
preferred_locations: Option<Value>,
#[serde(default)]
preferred_job_types: Option<Value>,
#[serde(default)]
preferred_work_modes: Option<Value>,
#[serde(default)]
preferred_skills: Option<Value>,
#[serde(default)]
min_salary: Option<i32>,
#[serde(default)]
max_salary: Option<i32>,
#[serde(default)]
max_applications_per_day: Option<i32>,
#[serde(default)]
require_user_approval: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AutoRequestSettingsBody {
#[serde(default)]
professional_role_code: Option<String>,
#[serde(default)]
is_enabled: Option<bool>,
#[serde(default)]
preferred_categories: Option<Value>,
#[serde(default)]
preferred_locations: Option<Value>,
#[serde(default)]
preferred_requirement_types: Option<Value>,
#[serde(default)]
min_budget: Option<i32>,
#[serde(default)]
max_budget: Option<i32>,
#[serde(default)]
max_requests_per_day: Option<i32>,
#[serde(default)]
require_user_approval: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AutoSuggestBody {
text: String,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LogQueryParams {
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
fn default_limit() -> i64 {
50
}
pub fn ai_auto_router() -> Router<AppState> {
Router::new()
.route("/job-seeker/auto-apply/settings", get(get_auto_apply_settings).post(update_auto_apply_settings))
.route("/job-seeker/auto-apply/suggest", post(auto_apply_suggest))
.route("/job-seeker/auto-apply/logs", get(get_auto_apply_logs))
.route("/professional/auto-request/settings", get(get_auto_request_settings).post(update_auto_request_settings))
.route("/professional/auto-request/suggest", post(auto_request_suggest))
.route("/professional/auto-request/logs", get(get_auto_request_logs))
}
async fn get_auto_apply_settings(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
match AiAutoApplySettingsRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => (StatusCode::OK, Json(serde_json::json!({ "settings": s }))).into_response(),
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({ "settings": Value::Null })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch auto-apply settings: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn update_auto_apply_settings(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AutoApplySettingsBody>,
) -> impl IntoResponse {
let current = AiAutoApplySettingsRepository::get_by_user_id(&state.pool, auth.user_id).await.ok().flatten();
let settings = AiAutoApplySettingsRepository::upsert(
&state.pool,
auth.user_id,
body.is_enabled.unwrap_or_else(|| current.as_ref().map(|s| s.is_enabled).unwrap_or(false)),
body.preferred_titles.or_else(|| current.as_ref().and_then(|s| s.preferred_titles.clone())),
body.preferred_locations.or_else(|| current.as_ref().and_then(|s| s.preferred_locations.clone())),
body.preferred_job_types.or_else(|| current.as_ref().and_then(|s| s.preferred_job_types.clone())),
body.preferred_work_modes.or_else(|| current.as_ref().and_then(|s| s.preferred_work_modes.clone())),
body.preferred_skills.or_else(|| current.as_ref().and_then(|s| s.preferred_skills.clone())),
body.min_salary.or_else(|| current.as_ref().and_then(|s| s.min_salary)),
body.max_salary.or_else(|| current.as_ref().and_then(|s| s.max_salary)),
body.max_applications_per_day.unwrap_or_else(|| current.as_ref().map(|s| s.max_applications_per_day).unwrap_or(3)),
body.require_user_approval.unwrap_or_else(|| current.as_ref().map(|s| s.require_user_approval).unwrap_or(true)),
)
.await;
match settings {
Ok(s) => (StatusCode::OK, Json(serde_json::json!({ "settings": s }))).into_response(),
Err(e) => {
tracing::error!("Failed to update auto-apply settings: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn auto_apply_suggest(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AutoSuggestBody>,
) -> impl IntoResponse {
ai_suggest(
&state,
&auth,
"auto_apply_suggest",
&body.text,
body.model.as_deref(),
"You are a job matching assistant. Given the candidate preferences below, suggest which jobs they should apply to and why. Keep it concise.",
)
.await
}
async fn get_auto_request_settings(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
match AiAutoRequestSettingsRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => (
StatusCode::OK,
Json(serde_json::json!({ "settings": s })),
)
.into_response(),
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({ "settings": Value::Null })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch auto-request settings: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn update_auto_request_settings(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AutoRequestSettingsBody>,
) -> impl IntoResponse {
let current = AiAutoRequestSettingsRepository::get_by_user_id(&state.pool, auth.user_id)
.await
.ok()
.flatten();
let professional_role_code = body
.professional_role_code
.or_else(|| current.as_ref().map(|s| s.professional_role_code.clone()))
.unwrap_or_default();
let settings = AiAutoRequestSettingsRepository::upsert(
&state.pool,
auth.user_id,
&professional_role_code,
body.is_enabled.unwrap_or_else(|| current.as_ref().map(|s| s.is_enabled).unwrap_or(false)),
body.preferred_categories.or_else(|| current.as_ref().and_then(|s| s.preferred_categories.clone())),
body.preferred_locations.or_else(|| current.as_ref().and_then(|s| s.preferred_locations.clone())),
body.preferred_requirement_types.or_else(|| current.as_ref().and_then(|s| s.preferred_requirement_types.clone())),
body.min_budget.or_else(|| current.as_ref().and_then(|s| s.min_budget)),
body.max_budget.or_else(|| current.as_ref().and_then(|s| s.max_budget)),
body.max_requests_per_day.unwrap_or_else(|| current.as_ref().map(|s| s.max_requests_per_day).unwrap_or(3)),
body.require_user_approval.unwrap_or_else(|| current.as_ref().map(|s| s.require_user_approval).unwrap_or(true)),
)
.await;
match settings {
Ok(s) => (StatusCode::OK, Json(serde_json::json!({ "settings": s }))).into_response(),
Err(e) => {
tracing::error!("Failed to update auto-request settings: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn auto_request_suggest(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<AutoSuggestBody>,
) -> impl IntoResponse {
ai_suggest(
&state,
&auth,
"auto_request_suggest",
&body.text,
body.model.as_deref(),
"You are a lead/requirement matching assistant. Given the professional's preferences below, suggest which requirements they should respond to and why. Keep it concise.",
)
.await
}
async fn ai_suggest(
state: &AppState,
auth: &AuthUser,
feature_code: &str,
text: &str,
requested_model: Option<&str>,
system_prompt: &str,
) -> impl IntoResponse {
let role_code = auth.claims.active_role.clone();
let (sub, plan) = match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&role_code),
)
.await
{
Ok(sp) => sp,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
if !plans::is_feature_allowed(&plan, feature_code) {
let e = plans::PlanError::FeatureNotAllowed {
feature: feature_code.to_string(),
};
return (e.status_code(), Json(e.error_body())).into_response();
}
let feature_cost = match credits::get_feature_cost(&state.pool, feature_code).await {
Ok(c) => c,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
let model_alias = match model_router::resolve_model(&feature_cost, &plan, requested_model) {
Ok(m) => m,
Err(e) => return (e.status_code(), Json(e.error_body())).into_response(),
};
if credits::remaining_credits(&sub) < feature_cost.credit_cost {
let e = credits::CreditError::InsufficientCredits;
return (e.status_code(), Json(e.error_body())).into_response();
}
let client = match litellm::LiteLlmClient::new() {
Ok(c) => c,
Err(e) => {
tracing::error!("LiteLLM client creation failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "AI service unavailable" })),
)
.into_response();
}
};
let full_prompt = format!("{}\n\nPreferences/Context: {}\n\nSuggestions:", system_prompt, text);
let (response_text, usage, request_id) = match client
.chat_completion_text(&model_alias, None, &full_prompt, None)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("LiteLLM call failed: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "AI model call failed" })),
)
.into_response();
}
};
if let Err(e) = credits::charge_feature(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
Some(&model_alias),
)
.await
{
tracing::error!(
"Failed to charge AI feature {} for user {}: {}",
feature_code,
auth.user_id,
e
);
}
usage::log_usage(
&state.pool,
auth.user_id,
Some(&role_code),
feature_code,
&model_alias,
feature_cost.credit_cost,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
"success",
request_id.as_deref(),
None,
)
.await;
let remaining_credits = credits::remaining_credits(&sub) - feature_cost.credit_cost;
let remaining_daily = credits::remaining_daily_actions(&sub, &plan) - 1;
(
StatusCode::OK,
Json(serde_json::json!({
"suggestions": response_text,
"model": model_alias,
"credits_charged": feature_cost.credit_cost,
"remaining_credits": remaining_credits,
"remaining_daily_actions": remaining_daily.max(0),
"request_id": request_id,
})),
)
.into_response()
}
async fn get_auto_apply_logs(
State(state): State<AppState>,
auth: AuthUser,
Query(params): Query<LogQueryParams>,
) -> impl IntoResponse {
match AiAutoApplyLogRepository::list_by_user(&state.pool, auth.user_id, params.limit, params.offset).await {
Ok(logs) => (StatusCode::OK, Json(serde_json::json!({ "logs": logs }))).into_response(),
Err(e) => {
tracing::error!("Failed to fetch auto-apply logs: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn get_auto_request_logs(
State(state): State<AppState>,
auth: AuthUser,
Query(params): Query<LogQueryParams>,
) -> impl IntoResponse {
match AiAutoRequestLogRepository::list_by_user(&state.pool, auth.user_id, params.limit, params.offset).await {
Ok(logs) => (StatusCode::OK, Json(serde_json::json!({ "logs": logs }))).into_response(),
Err(e) => {
tracing::error!("Failed to fetch auto-request logs: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}

View file

@ -1,9 +1,11 @@
pub mod admin;
pub mod admin_ai;
pub mod admin_email;
pub mod activity_logs;
pub mod approvals;
pub mod auth;
pub mod ai;
pub mod ai_auto;
pub mod ai_phase4;
pub mod ai_prompts;
pub mod config;

View file

@ -1,3 +1,4 @@
mod ai;
mod handlers;
mod mail;
@ -110,7 +111,15 @@ async fn main() {
// ── Email Management (admin) ──────────────────────────────────────
.nest("/api/admin/email", handlers::admin_email::router())
// ── AI Assistant ──────────────────────────────────────────────────
.nest("/api/ai", handlers::ai::ai_router())
.nest("/api/ai", handlers::ai::ai_router().layer(axum::middleware::from_fn_with_state(
(),
crate::ai::middleware::ai_access_middleware,
)))
.nest("/api/admin/ai", handlers::admin_ai::admin_ai_router())
.nest("/api/ai/auto", handlers::ai_auto::ai_auto_router().layer(axum::middleware::from_fn_with_state(
(),
crate::ai::middleware::ai_access_middleware,
)))
.route("/health", get(|| async { "Users OK" }))
.with_state(state);

View file

@ -1,4 +1,4 @@
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /usr/src/app
@ -14,7 +14,7 @@ ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin video_editors --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
FROM registry.nxtgauge.com/alpine:latest AS runtime
RUN apk add --no-cache ca-certificates

View file

@ -0,0 +1,9 @@
DROP TABLE IF EXISTS ai_auto_request_logs;
DROP TABLE IF EXISTS ai_auto_request_settings;
DROP TABLE IF EXISTS ai_auto_apply_logs;
DROP TABLE IF EXISTS ai_auto_apply_settings;
DROP TABLE IF EXISTS ai_credit_transactions;
DROP TABLE IF EXISTS ai_usage_logs;
DROP TABLE IF EXISTS ai_feature_costs;
DROP TABLE IF EXISTS user_ai_subscriptions;
DROP TABLE IF EXISTS ai_plans;

View file

@ -0,0 +1,166 @@
CREATE TABLE ai_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
monthly_credits INT NOT NULL,
daily_action_limit INT NOT NULL,
allowed_models JSONB NOT NULL,
allowed_features JSONB NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE user_ai_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES ai_plans(id),
role_code VARCHAR(50),
monthly_credits_total INT NOT NULL,
monthly_credits_used INT NOT NULL DEFAULT 0,
purchased_credits_total INT NOT NULL DEFAULT 0,
purchased_credits_used INT NOT NULL DEFAULT 0,
daily_actions_used INT NOT NULL DEFAULT 0,
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_feature_costs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
feature_code VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(150) NOT NULL,
default_model VARCHAR(100) NOT NULL,
credit_cost INT NOT NULL,
max_input_tokens INT,
max_output_tokens INT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
role_code VARCHAR(50),
feature_code VARCHAR(100) NOT NULL,
model_alias VARCHAR(100) NOT NULL,
credits_charged INT NOT NULL,
input_tokens INT,
output_tokens INT,
total_tokens INT,
status VARCHAR(30) NOT NULL,
request_id VARCHAR(100),
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
transaction_type VARCHAR(50) NOT NULL,
source VARCHAR(50) NOT NULL,
credits INT NOT NULL,
balance_after INT NOT NULL,
reference_id UUID,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_auto_apply_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
preferred_titles JSONB,
preferred_locations JSONB,
preferred_job_types JSONB,
preferred_work_modes JSONB,
preferred_skills JSONB,
min_salary INT,
max_salary INT,
max_applications_per_day INT NOT NULL DEFAULT 3,
require_user_approval BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_auto_apply_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
job_id UUID NOT NULL,
match_score INT,
status VARCHAR(50) NOT NULL,
credits_charged INT NOT NULL DEFAULT 0,
generated_cover_letter TEXT,
applied_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_auto_request_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
professional_role_code VARCHAR(50) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
preferred_categories JSONB,
preferred_locations JSONB,
preferred_requirement_types JSONB,
min_budget INT,
max_budget INT,
max_requests_per_day INT NOT NULL DEFAULT 3,
require_user_approval BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_auto_request_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
requirement_id UUID NOT NULL,
professional_role_code VARCHAR(50) NOT NULL,
match_score INT,
status VARCHAR(50) NOT NULL,
credits_charged INT NOT NULL DEFAULT 0,
requested_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_ai_subscriptions_user_id ON user_ai_subscriptions(user_id);
CREATE INDEX idx_user_ai_subscriptions_plan_id ON user_ai_subscriptions(plan_id);
CREATE INDEX idx_ai_usage_logs_user_id ON ai_usage_logs(user_id);
CREATE INDEX idx_ai_usage_logs_feature_code ON ai_usage_logs(feature_code);
CREATE INDEX idx_ai_usage_logs_created_at ON ai_usage_logs(created_at);
CREATE INDEX idx_ai_credit_transactions_user_id ON ai_credit_transactions(user_id);
CREATE INDEX idx_ai_auto_apply_logs_user_id ON ai_auto_apply_logs(user_id);
CREATE INDEX idx_ai_auto_apply_logs_job_id ON ai_auto_apply_logs(job_id);
CREATE INDEX idx_ai_auto_request_logs_user_id ON ai_auto_request_logs(user_id);
CREATE INDEX idx_ai_auto_request_logs_requirement_id ON ai_auto_request_logs(requirement_id);
INSERT INTO ai_plans (code, name, monthly_credits, daily_action_limit, allowed_models, allowed_features) VALUES
('free', 'Free', 10, 3, '["askash-fast"]', '["help_answer", "platform_guidance", "form_fill", "form_validate", "jd_generate"]'),
('pro', 'Pro', 100, 15, '["askash-fast", "askash-main"]', '["help_answer", "platform_guidance", "form_fill", "form_validate", "job_match", "auto_apply_suggest", "auto_apply_execute", "cover_letter_generate", "requirement_match", "auto_request_suggest", "auto_request_execute"]'),
('business', 'Business', 300, 40, '["askash-fast", "askash-main"]', '["help_answer", "platform_guidance", "form_fill", "form_validate", "jd_generate", "jd_improve", "skills_extract", "candidate_match", "candidate_shortlist"]'),
('enterprise', 'Enterprise', 50000, 999999, '["askash-fast", "askash-main"]', '["help_answer", "platform_guidance", "form_fill", "form_validate", "jd_generate", "jd_improve", "skills_extract", "candidate_match", "candidate_shortlist", "job_match", "auto_apply_suggest", "auto_apply_execute", "cover_letter_generate", "requirement_match", "auto_request_suggest", "auto_request_execute", "admin_support_reply", "admin_ticket_summary", "admin_verification_summary", "abuse_check"]');
INSERT INTO ai_feature_costs (feature_code, display_name, default_model, credit_cost) VALUES
('help_answer', 'Help Answer', 'askash-fast', 1),
('platform_guidance', 'Platform Guidance', 'askash-fast', 1),
('form_fill', 'Form Fill', 'askash-fast', 2),
('form_validate', 'Form Validate', 'askash-fast', 1),
('jd_generate', 'Job Description Generate', 'askash-main', 5),
('jd_improve', 'Job Description Improve', 'askash-main', 4),
('skills_extract', 'Skills Extract', 'askash-fast', 1),
('candidate_match', 'Candidate Match', 'askash-fast', 1),
('candidate_shortlist', 'Candidate Shortlist', 'askash-fast', 2),
('job_match', 'Job Match', 'askash-fast', 1),
('auto_apply_suggest', 'Auto Apply Suggest', 'askash-fast', 2),
('auto_apply_execute', 'Auto Apply Execute', 'askash-main', 5),
('cover_letter_generate', 'Cover Letter Generate', 'askash-main', 5),
('requirement_match', 'Requirement Match', 'askash-fast', 1),
('auto_request_suggest', 'Auto Request Suggest', 'askash-fast', 2),
('auto_request_execute', 'Auto Request Execute', 'backend', 3),
('admin_support_reply', 'Admin Support Reply', 'askash-main', 3),
('admin_ticket_summary', 'Admin Ticket Summary', 'askash-fast', 1),
('admin_verification_summary', 'Admin Verification Summary', 'askash-fast', 1),
('abuse_check', 'Abuse Check', 'askash-fast', 1);

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_credit_packages_active;
DROP TABLE IF EXISTS ai_credit_packages;

View file

@ -0,0 +1,18 @@
CREATE TABLE ai_credit_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(150) NOT NULL,
description TEXT,
credits INT NOT NULL,
price_inr INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ai_credit_packages_active ON ai_credit_packages(is_active, price_inr);
INSERT INTO ai_credit_packages (name, description, credits, price_inr) VALUES
('Starter AI Credits', '50 AI credits for casual usage', 50, 99),
('Pro AI Credits', '200 AI credits for power users', 200, 349),
('Business AI Credits', '750 AI credits for teams', 750, 999),
('Enterprise AI Credits', '2500 AI credits for heavy usage', 2500, 2499);

View file

@ -0,0 +1,5 @@
pub mod models;
pub mod repository;
pub use models::*;
pub use repository::*;

View file

@ -0,0 +1,153 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiPlan {
pub id: Uuid,
pub code: String,
pub name: String,
pub monthly_credits: i32,
pub daily_action_limit: i32,
pub allowed_models: serde_json::Value,
pub allowed_features: serde_json::Value,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UserAiSubscription {
pub id: Uuid,
pub user_id: Uuid,
pub plan_id: Uuid,
pub role_code: Option<String>,
pub monthly_credits_total: i32,
pub monthly_credits_used: i32,
pub purchased_credits_total: i32,
pub purchased_credits_used: i32,
pub daily_actions_used: i32,
pub current_period_start: DateTime<Utc>,
pub current_period_end: DateTime<Utc>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiFeatureCost {
pub id: Uuid,
pub feature_code: String,
pub display_name: String,
pub default_model: String,
pub credit_cost: i32,
pub max_input_tokens: Option<i32>,
pub max_output_tokens: Option<i32>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiUsageLog {
pub id: Uuid,
pub user_id: Uuid,
pub role_code: Option<String>,
pub feature_code: String,
pub model_alias: String,
pub credits_charged: i32,
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
pub status: String,
pub request_id: Option<String>,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiCreditTransaction {
pub id: Uuid,
pub user_id: Uuid,
pub transaction_type: String,
pub source: String,
pub credits: i32,
pub balance_after: i32,
pub reference_id: Option<Uuid>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiAutoApplySettings {
pub id: Uuid,
pub user_id: Uuid,
pub is_enabled: bool,
pub preferred_titles: Option<serde_json::Value>,
pub preferred_locations: Option<serde_json::Value>,
pub preferred_job_types: Option<serde_json::Value>,
pub preferred_work_modes: Option<serde_json::Value>,
pub preferred_skills: Option<serde_json::Value>,
pub min_salary: Option<i32>,
pub max_salary: Option<i32>,
pub max_applications_per_day: i32,
pub require_user_approval: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiCreditPackage {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub credits: i32,
pub price_inr: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiAutoApplyLog {
pub id: Uuid,
pub user_id: Uuid,
pub job_id: Uuid,
pub match_score: Option<i32>,
pub status: String,
pub credits_charged: i32,
pub generated_cover_letter: Option<String>,
pub applied_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiAutoRequestSettings {
pub id: Uuid,
pub user_id: Uuid,
pub professional_role_code: String,
pub is_enabled: bool,
pub preferred_categories: Option<serde_json::Value>,
pub preferred_locations: Option<serde_json::Value>,
pub preferred_requirement_types: Option<serde_json::Value>,
pub min_budget: Option<i32>,
pub max_budget: Option<i32>,
pub max_requests_per_day: i32,
pub require_user_approval: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AiAutoRequestLog {
pub id: Uuid,
pub user_id: Uuid,
pub requirement_id: Uuid,
pub professional_role_code: String,
pub match_score: Option<i32>,
pub status: String,
pub credits_charged: i32,
pub requested_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}

View file

@ -0,0 +1,614 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::ai::*;
pub struct AiPlanRepository;
impl AiPlanRepository {
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<AiPlan>, sqlx::Error> {
sqlx::query_as::<_, AiPlan>("SELECT * FROM ai_plans WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn get_by_code(pool: &PgPool, code: &str) -> Result<Option<AiPlan>, sqlx::Error> {
sqlx::query_as::<_, AiPlan>(
"SELECT * FROM ai_plans WHERE code = $1 AND is_active = true"
)
.bind(code)
.fetch_optional(pool)
.await
}
pub async fn list_active(pool: &PgPool) -> Result<Vec<AiPlan>, sqlx::Error> {
sqlx::query_as::<_, AiPlan>(
"SELECT * FROM ai_plans WHERE is_active = true ORDER BY monthly_credits"
)
.fetch_all(pool)
.await
}
pub async fn create(
pool: &PgPool,
code: &str,
name: &str,
monthly_credits: i32,
daily_action_limit: i32,
allowed_models: serde_json::Value,
allowed_features: serde_json::Value,
) -> Result<AiPlan, sqlx::Error> {
sqlx::query_as::<_, AiPlan>(
r#"
INSERT INTO ai_plans (code, name, monthly_credits, daily_action_limit, allowed_models, allowed_features)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#
)
.bind(code)
.bind(name)
.bind(monthly_credits)
.bind(daily_action_limit)
.bind(allowed_models)
.bind(allowed_features)
.fetch_one(pool)
.await
}
pub async fn update(
pool: &PgPool,
id: Uuid,
name: Option<&str>,
monthly_credits: Option<i32>,
daily_action_limit: Option<i32>,
allowed_models: Option<serde_json::Value>,
allowed_features: Option<serde_json::Value>,
is_active: Option<bool>,
) -> Result<AiPlan, sqlx::Error> {
sqlx::query_as::<_, AiPlan>(
r#"
UPDATE ai_plans SET
name = COALESCE($2, name),
monthly_credits = COALESCE($3, monthly_credits),
daily_action_limit = COALESCE($4, daily_action_limit),
allowed_models = COALESCE($5, allowed_models),
allowed_features = COALESCE($6, allowed_features),
is_active = COALESCE($7, is_active),
updated_at = NOW()
WHERE id = $1
RETURNING *
"#
)
.bind(id)
.bind(name)
.bind(monthly_credits)
.bind(daily_action_limit)
.bind(allowed_models)
.bind(allowed_features)
.bind(is_active)
.fetch_one(pool)
.await
}
}
pub struct UserAiSubscriptionRepository;
impl UserAiSubscriptionRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<UserAiSubscription>, sqlx::Error> {
sqlx::query_as::<_, UserAiSubscription>(
"SELECT * FROM user_ai_subscriptions WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn create(
pool: &PgPool,
user_id: Uuid,
plan_id: Uuid,
role_code: Option<&str>,
monthly_credits_total: i32,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
) -> Result<UserAiSubscription, sqlx::Error> {
sqlx::query_as::<_, UserAiSubscription>(
r#"
INSERT INTO user_ai_subscriptions (
user_id, plan_id, role_code, monthly_credits_total,
current_period_start, current_period_end
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#
)
.bind(user_id)
.bind(plan_id)
.bind(role_code)
.bind(monthly_credits_total)
.bind(period_start)
.bind(period_end)
.fetch_one(pool)
.await
}
pub async fn update_plan(
pool: &PgPool,
user_id: Uuid,
plan_id: Uuid,
monthly_credits_total: i32,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
) -> Result<UserAiSubscription, sqlx::Error> {
sqlx::query_as::<_, UserAiSubscription>(
r#"
UPDATE user_ai_subscriptions
SET plan_id = $2,
monthly_credits_total = $3,
monthly_credits_used = 0,
daily_actions_used = 0,
current_period_start = $4,
current_period_end = $5,
updated_at = NOW()
WHERE user_id = $1
RETURNING *
"#
)
.bind(user_id)
.bind(plan_id)
.bind(monthly_credits_total)
.bind(period_start)
.bind(period_end)
.fetch_one(pool)
.await
}
pub async fn add_purchased_credits(
pool: &PgPool,
user_id: Uuid,
credits: i32,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE user_ai_subscriptions SET purchased_credits_total = purchased_credits_total + $1, updated_at = NOW() WHERE user_id = $2"
)
.bind(credits)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn increment_daily_actions(
pool: &PgPool,
user_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE user_ai_subscriptions SET daily_actions_used = daily_actions_used + 1, updated_at = NOW() WHERE user_id = $1"
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn charge_credits(
pool: &PgPool,
user_id: Uuid,
credits: i32,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE user_ai_subscriptions SET monthly_credits_used = monthly_credits_used + $1, updated_at = NOW() WHERE user_id = $2"
)
.bind(credits)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn reset_daily_actions(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE user_ai_subscriptions SET daily_actions_used = 0, updated_at = NOW()"
)
.execute(pool)
.await?;
Ok(())
}
pub async fn reset_monthly_credits(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE user_ai_subscriptions SET monthly_credits_used = 0, daily_actions_used = 0, updated_at = NOW()"
)
.execute(pool)
.await?;
Ok(())
}
}
pub struct AiFeatureCostRepository;
impl AiFeatureCostRepository {
pub async fn get_by_code(
pool: &PgPool,
feature_code: &str,
) -> Result<Option<AiFeatureCost>, sqlx::Error> {
sqlx::query_as::<_, AiFeatureCost>(
"SELECT * FROM ai_feature_costs WHERE feature_code = $1 AND is_active = true"
)
.bind(feature_code)
.fetch_optional(pool)
.await
}
pub async fn list_active(pool: &PgPool) -> Result<Vec<AiFeatureCost>, sqlx::Error> {
sqlx::query_as::<_, AiFeatureCost>(
"SELECT * FROM ai_feature_costs WHERE is_active = true"
)
.fetch_all(pool)
.await
}
pub async fn create(
pool: &PgPool,
feature_code: &str,
display_name: &str,
default_model: &str,
credit_cost: i32,
max_input_tokens: Option<i32>,
max_output_tokens: Option<i32>,
) -> Result<AiFeatureCost, sqlx::Error> {
sqlx::query_as::<_, AiFeatureCost>(
r#"
INSERT INTO ai_feature_costs (feature_code, display_name, default_model, credit_cost, max_input_tokens, max_output_tokens)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#
)
.bind(feature_code)
.bind(display_name)
.bind(default_model)
.bind(credit_cost)
.bind(max_input_tokens)
.bind(max_output_tokens)
.fetch_one(pool)
.await
}
pub async fn update(
pool: &PgPool,
id: Uuid,
display_name: Option<&str>,
default_model: Option<&str>,
credit_cost: Option<i32>,
max_input_tokens: Option<i32>,
max_output_tokens: Option<i32>,
is_active: Option<bool>,
) -> Result<AiFeatureCost, sqlx::Error> {
sqlx::query_as::<_, AiFeatureCost>(
r#"
UPDATE ai_feature_costs SET
display_name = COALESCE($2, display_name),
default_model = COALESCE($3, default_model),
credit_cost = COALESCE($4, credit_cost),
max_input_tokens = COALESCE($5, max_input_tokens),
max_output_tokens = COALESCE($6, max_output_tokens),
is_active = COALESCE($7, is_active),
updated_at = NOW()
WHERE id = $1
RETURNING *
"#
)
.bind(id)
.bind(display_name)
.bind(default_model)
.bind(credit_cost)
.bind(max_input_tokens)
.bind(max_output_tokens)
.bind(is_active)
.fetch_one(pool)
.await
}
}
pub struct AiCreditPackageRepository;
impl AiCreditPackageRepository {
pub async fn list_active(pool: &PgPool) -> Result<Vec<AiCreditPackage>, sqlx::Error> {
sqlx::query_as::<_, AiCreditPackage>(
"SELECT * FROM ai_credit_packages WHERE is_active = true ORDER BY price_inr ASC"
)
.fetch_all(pool)
.await
}
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<AiCreditPackage>, sqlx::Error> {
sqlx::query_as::<_, AiCreditPackage>(
"SELECT * FROM ai_credit_packages WHERE id = $1 AND is_active = true"
)
.bind(id)
.fetch_optional(pool)
.await
}
}
pub struct AiUsageLogRepository;
impl AiUsageLogRepository {
pub async fn create(
pool: &PgPool,
user_id: Uuid,
role_code: Option<&str>,
feature_code: &str,
model_alias: &str,
credits_charged: i32,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
status: &str,
request_id: Option<&str>,
error_message: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO ai_usage_logs (
user_id, role_code, feature_code, model_alias, credits_charged,
input_tokens, output_tokens, total_tokens, status, request_id, error_message
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"#
)
.bind(user_id)
.bind(role_code)
.bind(feature_code)
.bind(model_alias)
.bind(credits_charged)
.bind(input_tokens)
.bind(output_tokens)
.bind(total_tokens)
.bind(status)
.bind(request_id)
.bind(error_message)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_by_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AiUsageLog>, sqlx::Error> {
sqlx::query_as::<_, AiUsageLog>(
"SELECT * FROM ai_usage_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
}
pub struct AiCreditTransactionRepository;
impl AiCreditTransactionRepository {
pub async fn create(
pool: &PgPool,
user_id: Uuid,
transaction_type: &str,
source: &str,
credits: i32,
balance_after: i32,
reference_id: Option<Uuid>,
description: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO ai_credit_transactions (user_id, transaction_type, source, credits, balance_after, reference_id, description) VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(user_id)
.bind(transaction_type)
.bind(source)
.bind(credits)
.bind(balance_after)
.bind(reference_id)
.bind(description)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_by_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AiCreditTransaction>, sqlx::Error> {
sqlx::query_as::<_, AiCreditTransaction>(
"SELECT * FROM ai_credit_transactions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
}
pub struct AiAutoApplySettingsRepository;
impl AiAutoApplySettingsRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<AiAutoApplySettings>, sqlx::Error> {
sqlx::query_as::<_, AiAutoApplySettings>(
"SELECT * FROM ai_auto_apply_settings WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
is_enabled: bool,
preferred_titles: Option<serde_json::Value>,
preferred_locations: Option<serde_json::Value>,
preferred_job_types: Option<serde_json::Value>,
preferred_work_modes: Option<serde_json::Value>,
preferred_skills: Option<serde_json::Value>,
min_salary: Option<i32>,
max_salary: Option<i32>,
max_applications_per_day: i32,
require_user_approval: bool,
) -> Result<AiAutoApplySettings, sqlx::Error> {
sqlx::query_as::<_, AiAutoApplySettings>(
r#"
INSERT INTO ai_auto_apply_settings (
user_id, is_enabled, preferred_titles, preferred_locations, preferred_job_types,
preferred_work_modes, preferred_skills, min_salary, max_salary,
max_applications_per_day, require_user_approval
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id) DO UPDATE SET
is_enabled = EXCLUDED.is_enabled,
preferred_titles = EXCLUDED.preferred_titles,
preferred_locations = EXCLUDED.preferred_locations,
preferred_job_types = EXCLUDED.preferred_job_types,
preferred_work_modes = EXCLUDED.preferred_work_modes,
preferred_skills = EXCLUDED.preferred_skills,
min_salary = EXCLUDED.min_salary,
max_salary = EXCLUDED.max_salary,
max_applications_per_day = EXCLUDED.max_applications_per_day,
require_user_approval = EXCLUDED.require_user_approval,
updated_at = NOW()
RETURNING *
"#
)
.bind(user_id)
.bind(is_enabled)
.bind(preferred_titles)
.bind(preferred_locations)
.bind(preferred_job_types)
.bind(preferred_work_modes)
.bind(preferred_skills)
.bind(min_salary)
.bind(max_salary)
.bind(max_applications_per_day)
.bind(require_user_approval)
.fetch_one(pool)
.await
}
}
pub struct AiAutoRequestSettingsRepository;
impl AiAutoRequestSettingsRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<AiAutoRequestSettings>, sqlx::Error> {
sqlx::query_as::<_, AiAutoRequestSettings>(
"SELECT * FROM ai_auto_request_settings WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
professional_role_code: &str,
is_enabled: bool,
preferred_categories: Option<serde_json::Value>,
preferred_locations: Option<serde_json::Value>,
preferred_requirement_types: Option<serde_json::Value>,
min_budget: Option<i32>,
max_budget: Option<i32>,
max_requests_per_day: i32,
require_user_approval: bool,
) -> Result<AiAutoRequestSettings, sqlx::Error> {
sqlx::query_as::<_, AiAutoRequestSettings>(
r#"
INSERT INTO ai_auto_request_settings (
user_id, professional_role_code, is_enabled, preferred_categories,
preferred_locations, preferred_requirement_types, min_budget, max_budget,
max_requests_per_day, require_user_approval
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (user_id) DO UPDATE SET
professional_role_code = EXCLUDED.professional_role_code,
is_enabled = EXCLUDED.is_enabled,
preferred_categories = EXCLUDED.preferred_categories,
preferred_locations = EXCLUDED.preferred_locations,
preferred_requirement_types = EXCLUDED.preferred_requirement_types,
min_budget = EXCLUDED.min_budget,
max_budget = EXCLUDED.max_budget,
max_requests_per_day = EXCLUDED.max_requests_per_day,
require_user_approval = EXCLUDED.require_user_approval,
updated_at = NOW()
RETURNING *
"#
)
.bind(user_id)
.bind(professional_role_code)
.bind(is_enabled)
.bind(preferred_categories)
.bind(preferred_locations)
.bind(preferred_requirement_types)
.bind(min_budget)
.bind(max_budget)
.bind(max_requests_per_day)
.bind(require_user_approval)
.fetch_one(pool)
.await
}
}
pub struct AiAutoApplyLogRepository;
impl AiAutoApplyLogRepository {
pub async fn list_by_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AiAutoApplyLog>, sqlx::Error> {
sqlx::query_as::<_, AiAutoApplyLog>(
"SELECT * FROM ai_auto_apply_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
}
pub struct AiAutoRequestLogRepository;
impl AiAutoRequestLogRepository {
pub async fn list_by_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AiAutoRequestLog>, sqlx::Error> {
sqlx::query_as::<_, AiAutoRequestLog>(
"SELECT * FROM ai_auto_request_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
}

View file

@ -1,30 +1,31 @@
pub mod job;
pub mod config;
pub mod onboarding_state;
pub mod activity_log;
pub mod role;
pub mod user;
pub mod photographer;
pub mod tutor;
pub mod company;
pub mod job_seeker;
pub mod customer;
pub mod makeup_artist;
pub mod developer;
pub mod video_editor;
pub mod graphic_designer;
pub mod social_media_manager;
pub mod fitness_trainer;
pub mod catering_service;
pub mod ugc_content_creator;
pub mod requirement;
pub mod lead_request;
pub mod ai;
pub mod application;
pub mod professional;
pub mod employee;
pub mod catering_service;
pub mod company;
pub mod config;
pub mod customer;
pub mod department;
pub mod designation;
pub mod verification;
pub mod user_role_profile;
pub mod developer;
pub mod employee;
pub mod fitness_trainer;
pub mod graphic_designer;
pub mod job;
pub mod job_seeker;
pub mod lead_request;
pub mod makeup_artist;
pub mod onboarding_state;
pub mod photographer;
pub mod professional;
pub mod requirement;
pub mod role;
pub mod social_media_manager;
pub mod tracecoin_wallet;
pub mod tutor;
pub mod ugc_content_creator;
pub mod user;
pub mod user_role_profile;
pub mod verification;
pub mod video_editor;

View file

@ -0,0 +1,408 @@
# Nxtgauge Tracecoin Packages
## Tracecoin Base Rule
**₹1 = 1 Tracecoin**
Bonus Tracecoins are added only for bigger packages.
---
# 1. Company / Employer Packages
Companies use Tracecoins for candidate contact unlocks, extra jobs, job boosts, and AI job tools.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Company Starter | ₹499 | 500 | 50 | 550 | 60 days |
| Company Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Company Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Company Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
| Company Enterprise | ₹9,999 | 10,000 | 3,000 | 13,000 | 365 days |
## Company Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Unlock candidate contact | 10 |
| Extra job post | 50 |
| Extend job for 30 days | 75 |
| Featured job for 7 days | 100 |
| Featured company profile for 7 days | 150 |
| AI job description generation | 5 |
| AI candidate shortlisting help | 10 |
---
# 2. Job Seeker
**Job Seeker does not need Tracecoins.**
Job seekers should be free because companies are the paying side in the employment module.
| Feature | Status |
|---|---|
| Create profile | Free |
| Upload resume | Free |
| Apply to jobs | Free |
| Receive company contact | Free |
| Basic AI resume suggestion | Free or limited daily |
| Basic AI cover letter | Free or limited daily |
---
# 3. Customer Packages
Customers use Tracecoins for accepting professionals, direct contact unlocks, featured requirements, and AI requirement writing.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Customer Starter | ₹199 | 200 | 0 | 200 | 30 days |
| Customer Basic | ₹499 | 500 | 50 | 550 | 60 days |
| Customer Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Customer Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Customer Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Customer Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept professional contact request | 10 |
| Unlock professional direct contact | 10 |
| Featured requirement for 7 days | 50 |
| Extend requirement for 7 days | 25 |
| AI requirement writing | 5 |
| Extra active requirement slot | 50 |
---
# 4. Photographer Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Photographer Starter | ₹299 | 300 | 0 | 300 | 30 days |
| Photographer Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Photographer Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Photographer Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Photographer Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 100 |
| Portfolio boost | 50 |
| AI profile improvement | 5 |
| AI proposal writing | 5 |
| Extra active lead slot | 50 |
---
# 5. Makeup Artist Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Makeup Starter | ₹299 | 300 | 0 | 300 | 30 days |
| Makeup Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Makeup Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Makeup Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Makeup Artist Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 100 |
| Portfolio boost | 50 |
| AI profile improvement | 5 |
| AI proposal writing | 5 |
| Extra active lead slot | 50 |
---
# 6. Tutor Packages
Tutors usually have smaller ticket-size leads, so pricing should be lower.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Tutor Starter | ₹199 | 200 | 0 | 200 | 30 days |
| Tutor Growth | ₹499 | 500 | 50 | 550 | 60 days |
| Tutor Pro | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Tutor Business | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
## Tutor Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept student/customer lead | 10 |
| Featured tutor profile for 7 days | 50 |
| Subject/category boost | 25 |
| AI profile improvement | 5 |
| AI proposal writing | 5 |
| Extra active lead slot | 30 |
---
# 7. Developer Packages
Developers can receive higher-value leads, so they can have stronger packages.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Developer Starter | ₹499 | 500 | 50 | 550 | 60 days |
| Developer Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Developer Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Developer Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
| Developer Enterprise | ₹9,999 | 10,000 | 3,000 | 13,000 | 365 days |
## Developer Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept project lead | 20 |
| Featured developer profile for 7 days | 100 |
| Skill/category boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# 8. Video Editor Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Video Editor Starter | ₹299 | 300 | 0 | 300 | 30 days |
| Video Editor Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Video Editor Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Video Editor Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Video Editor Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 100 |
| Portfolio boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# 9. Graphic Designer Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Graphic Designer Starter | ₹299 | 300 | 0 | 300 | 30 days |
| Graphic Designer Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Graphic Designer Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Graphic Designer Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Graphic Designer Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 100 |
| Portfolio boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# 10. Social Media Manager Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| SMM Starter | ₹299 | 300 | 0 | 300 | 30 days |
| SMM Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| SMM Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| SMM Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## Social Media Manager Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 100 |
| Category boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# 11. Fitness Trainer Packages
Fitness trainer packages should stay lower, similar to tutors.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Fitness Starter | ₹199 | 200 | 0 | 200 | 30 days |
| Fitness Growth | ₹499 | 500 | 50 | 550 | 60 days |
| Fitness Pro | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Fitness Business | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
## Fitness Trainer Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept customer lead | 10 |
| Featured profile for 7 days | 50 |
| Category boost | 25 |
| AI profile improvement | 5 |
| AI proposal writing | 5 |
| Extra active lead slot | 30 |
---
# 12. Catering Services Packages
Catering can have high-value event leads, so use higher packages.
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| Catering Starter | ₹499 | 500 | 50 | 550 | 60 days |
| Catering Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| Catering Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| Catering Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
| Catering Enterprise | ₹9,999 | 10,000 | 3,000 | 13,000 | 365 days |
## Catering Services Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept event lead | 20 |
| Featured catering profile for 7 days | 100 |
| Location/category boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# 13. UGC Content Creator Packages
| Package | Price | Base Tracecoins | Bonus Tracecoins | Total Tracecoins | Validity |
|---|---:|---:|---:|---:|---:|
| UGC Starter | ₹299 | 300 | 0 | 300 | 30 days |
| UGC Growth | ₹999 | 1,000 | 150 | 1,150 | 90 days |
| UGC Pro | ₹1,999 | 2,000 | 400 | 2,400 | 120 days |
| UGC Business | ₹4,999 | 5,000 | 1,250 | 6,250 | 180 days |
## UGC Content Creator Tracecoin Usage
| Action | Tracecoin Cost |
|---|---:|
| Accept brand/customer lead | 10 |
| Featured profile for 7 days | 100 |
| Portfolio boost | 50 |
| AI proposal writing | 5 |
| AI profile improvement | 5 |
| Extra active lead slot | 50 |
---
# Final Backend Package Groups
Even though the UI can show role-wise packages, the backend should keep packages grouped to avoid duplicate setup.
| Package Group Code | Roles |
|---|---|
| company_employer | Company / Employer |
| customer | Customer |
| professional_low_ticket | Tutor, Fitness Trainer |
| professional_standard | Photographer, Makeup Artist, Video Editor, Graphic Designer, Social Media Manager, UGC Content Creator |
| professional_high_ticket | Developer, Catering Services |
| job_seeker_free | Job Seeker |
---
# Package Group Pricing Summary
## Company / Employer
| Price | Total Tracecoins | Validity |
|---:|---:|---:|
| ₹499 | 550 | 60 days |
| ₹999 | 1,150 | 90 days |
| ₹1,999 | 2,400 | 120 days |
| ₹4,999 | 6,250 | 180 days |
| ₹9,999 | 13,000 | 365 days |
## Customer
| Price | Total Tracecoins | Validity |
|---:|---:|---:|
| ₹199 | 200 | 30 days |
| ₹499 | 550 | 60 days |
| ₹999 | 1,150 | 90 days |
| ₹1,999 | 2,400 | 120 days |
| ₹4,999 | 6,250 | 180 days |
## Low-Ticket Professionals
Tutor, Fitness Trainer
| Price | Total Tracecoins | Validity |
|---:|---:|---:|
| ₹199 | 200 | 30 days |
| ₹499 | 550 | 60 days |
| ₹999 | 1,150 | 90 days |
| ₹1,999 | 2,400 | 120 days |
## Standard Professionals
Photographer, Makeup Artist, Video Editor, Graphic Designer, Social Media Manager, UGC Creator
| Price | Total Tracecoins | Validity |
|---:|---:|---:|
| ₹299 | 300 | 30 days |
| ₹999 | 1,150 | 90 days |
| ₹1,999 | 2,400 | 120 days |
| ₹4,999 | 6,250 | 180 days |
## High-Ticket Professionals
Developer, Catering Services
| Price | Total Tracecoins | Validity |
|---:|---:|---:|
| ₹499 | 550 | 60 days |
| ₹999 | 1,150 | 90 days |
| ₹1,999 | 2,400 | 120 days |
| ₹4,999 | 6,250 | 180 days |
| ₹9,999 | 13,000 | 365 days |
---
# Important Business Rules
1. Job Seeker does not need Tracecoins.
2. Companies pay to unlock candidate contact.
3. Customers pay only when they accept or unlock a professional.
4. Professionals pay only when they accept a customer lead.
5. Tracecoins should be deducted only after confirmed action.
6. Rejected leads should not deduct Tracecoins.
7. Expired leads should not deduct Tracecoins.
8. Every Tracecoin transaction must be stored in immutable ledger.
9. Admin should be able to add manual Tracecoins with reason.
10. Refund should create reverse ledger entry, not delete old transaction.
---
# Recommended Launch Setup
For launch, keep the admin backend package groups simple:
- Company / Employer
- Customer
- Professional Low Ticket
- Professional Standard
- Professional High Ticket
- Job Seeker Free
This keeps the platform easy to manage while still showing role-wise pricing on the frontend.

View file

@ -60,9 +60,9 @@ ON CONFLICT DO NOTHING;
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
VALUES
('Starter Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 100, 49900, 'Starter company credit bundle for demo purchases.', true),
('Growth Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 300, 129900, 'Growth company credit bundle for regular hiring.', true),
('Scale Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 750, 299900, 'Scale company credit bundle for higher-volume recruitment.', true)
('Starter Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 100, 499, 'Starter company credit bundle for demo purchases.', true),
('Growth Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 300, 1299, 'Growth company credit bundle for regular hiring.', true),
('Scale Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 750, 2999, 'Scale company credit bundle for higher-volume recruitment.', true)
ON CONFLICT DO NOTHING;
COMMIT;

View file

@ -4,20 +4,19 @@
set -e
REGISTRY="registry.nxtgauge.com"
DATE_TAG=$(date +%Y%m%d)
echo "🔨 Building base image with all dependencies..."
# Build base image with cargo-chef
docker build \
-f Dockerfile.base \
-t ghcr.io/traceworks2023/nxtgauge-rust-base:latest \
-t ghcr.io/traceworks2023/nxtgauge-rust-base:$(date +%Y%m%d) \
.
docker build -f Dockerfile.base -t ${REGISTRY}/nxtgauge-rust-base:latest -t ${REGISTRY}/nxtgauge-rust-base:${DATE_TAG} .
echo "📤 Pushing base image..."
docker push ghcr.io/traceworks2023/nxtgauge-rust-base:latest
docker push ghcr.io/traceworks2023/nxtgauge-rust-base:$(date +%Y%m%d)
docker push ${REGISTRY}/nxtgauge-rust-base:latest
docker push ${REGISTRY}/nxtgauge-rust-base:${DATE_TAG}
echo "✅ Base image built and pushed!"
echo ""
echo "Now builds will use cached dependencies!"
echo "Build time: 15-20 min 30-60 seconds"
echo "Build time: 15-20 min -> 30-60 seconds"

View file

@ -4,7 +4,7 @@
set -e
REGISTRY="ghcr.io/traceworks2023"
REGISTRY="registry.nxtgauge.com"
SERVICE=${1:-""}
# Colors
@ -49,7 +49,7 @@ if [ -z "$SERVICE" ]; then
fi
# Check if base image exists
if ! docker image inspect ghcr.io/traceworks2023/nxtgauge-rust-base:latest &>/dev/null; then
if ! docker image inspect ${REGISTRY}/nxtgauge-rust-base:latest &>/dev/null; then
echo -e "${YELLOW}⚠️ Base image not found. Building dependencies...${NC}"
echo "This will take 10-15 minutes (one-time setup)"
./scripts/build-base-image.sh

View file

@ -0,0 +1,87 @@
BEGIN;
UPDATE pricing_packages
SET is_active = false
WHERE package_type = 'TRACECOIN_BUNDLE'
AND role_key IN (
'COMPANY',
'CUSTOMER',
'PHOTOGRAPHER',
'MAKEUP_ARTIST',
'TUTOR',
'DEVELOPER',
'VIDEO_EDITOR',
'GRAPHIC_DESIGNER',
'SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER',
'CATERING_SERVICES',
'UGC_CONTENT_CREATOR'
);
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
VALUES
('Company Starter', 'COMPANY', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Company Growth', 'COMPANY', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Company Pro', 'COMPANY', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Company Business', 'COMPANY', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Company Enterprise', 'COMPANY', 'TRACECOIN_BUNDLE', 13000, 9999, '13,000 Tracecoins total (10,000 base + 3,000 bonus). Valid for 365 days.', true),
('Customer Starter', 'CUSTOMER', 'TRACECOIN_BUNDLE', 200, 199, '200 Tracecoins total (200 base + 0 bonus). Valid for 30 days.', true),
('Customer Basic', 'CUSTOMER', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Customer Growth', 'CUSTOMER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Customer Pro', 'CUSTOMER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Customer Business', 'CUSTOMER', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Photographer Starter', 'PHOTOGRAPHER', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('Photographer Growth', 'PHOTOGRAPHER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Photographer Pro', 'PHOTOGRAPHER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Photographer Business', 'PHOTOGRAPHER', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Makeup Starter', 'MAKEUP_ARTIST', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('Makeup Growth', 'MAKEUP_ARTIST', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Makeup Pro', 'MAKEUP_ARTIST', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Makeup Business', 'MAKEUP_ARTIST', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Tutor Starter', 'TUTOR', 'TRACECOIN_BUNDLE', 200, 199, '200 Tracecoins total (200 base + 0 bonus). Valid for 30 days.', true),
('Tutor Growth', 'TUTOR', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Tutor Pro', 'TUTOR', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Tutor Business', 'TUTOR', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Developer Starter', 'DEVELOPER', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Developer Growth', 'DEVELOPER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Developer Pro', 'DEVELOPER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Developer Business', 'DEVELOPER', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Developer Enterprise', 'DEVELOPER', 'TRACECOIN_BUNDLE', 13000, 9999, '13,000 Tracecoins total (10,000 base + 3,000 bonus). Valid for 365 days.', true),
('Video Editor Starter', 'VIDEO_EDITOR', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('Video Editor Growth', 'VIDEO_EDITOR', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Video Editor Pro', 'VIDEO_EDITOR', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Video Editor Business', 'VIDEO_EDITOR', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Graphic Designer Starter', 'GRAPHIC_DESIGNER', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('Graphic Designer Growth', 'GRAPHIC_DESIGNER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Graphic Designer Pro', 'GRAPHIC_DESIGNER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Graphic Designer Business', 'GRAPHIC_DESIGNER', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('SMM Starter', 'SOCIAL_MEDIA_MANAGER', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('SMM Growth', 'SOCIAL_MEDIA_MANAGER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('SMM Pro', 'SOCIAL_MEDIA_MANAGER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('SMM Business', 'SOCIAL_MEDIA_MANAGER', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Fitness Starter', 'FITNESS_TRAINER', 'TRACECOIN_BUNDLE', 200, 199, '200 Tracecoins total (200 base + 0 bonus). Valid for 30 days.', true),
('Fitness Growth', 'FITNESS_TRAINER', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Fitness Pro', 'FITNESS_TRAINER', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Fitness Business', 'FITNESS_TRAINER', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Catering Starter', 'CATERING_SERVICES', 'TRACECOIN_BUNDLE', 550, 499, '550 Tracecoins total (500 base + 50 bonus). Valid for 60 days.', true),
('Catering Growth', 'CATERING_SERVICES', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('Catering Pro', 'CATERING_SERVICES', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('Catering Business', 'CATERING_SERVICES', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true),
('Catering Enterprise', 'CATERING_SERVICES', 'TRACECOIN_BUNDLE', 13000, 9999, '13,000 Tracecoins total (10,000 base + 3,000 bonus). Valid for 365 days.', true),
('UGC Starter', 'UGC_CONTENT_CREATOR', 'TRACECOIN_BUNDLE', 300, 299, '300 Tracecoins total (300 base + 0 bonus). Valid for 30 days.', true),
('UGC Growth', 'UGC_CONTENT_CREATOR', 'TRACECOIN_BUNDLE', 1150, 999, '1,150 Tracecoins total (1,000 base + 150 bonus). Valid for 90 days.', true),
('UGC Pro', 'UGC_CONTENT_CREATOR', 'TRACECOIN_BUNDLE', 2400, 1999, '2,400 Tracecoins total (2,000 base + 400 bonus). Valid for 120 days.', true),
('UGC Business', 'UGC_CONTENT_CREATOR', 'TRACECOIN_BUNDLE', 6250, 4999, '6,250 Tracecoins total (5,000 base + 1,250 bonus). Valid for 180 days.', true);
COMMIT;