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:
parent
ccf57df0c7
commit
c85e6af22e
61 changed files with 4622 additions and 245 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
40
Dockerfile.working
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
40
apps/cron/src/tasks/ai.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod ai;
|
||||
pub mod leads;
|
||||
pub mod requirements;
|
||||
pub mod jobs;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
374
apps/payments/src/ai_credits.rs
Normal file
374
apps/payments/src/ai_credits.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
154
apps/users/src/ai/credits.rs
Normal file
154
apps/users/src/ai/credits.rs
Normal 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)
|
||||
}
|
||||
215
apps/users/src/ai/litellm.rs
Normal file
215
apps/users/src/ai/litellm.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
164
apps/users/src/ai/middleware.rs
Normal file
164
apps/users/src/ai/middleware.rs
Normal 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
7
apps/users/src/ai/mod.rs
Normal 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;
|
||||
86
apps/users/src/ai/model_router.rs
Normal file
86
apps/users/src/ai/model_router.rs
Normal 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)
|
||||
}
|
||||
303
apps/users/src/ai/orchestrator.rs
Normal file
303
apps/users/src/ai/orchestrator.rs
Normal 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
183
apps/users/src/ai/plans.rs
Normal 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)
|
||||
}
|
||||
40
apps/users/src/ai/usage.rs
Normal file
40
apps/users/src/ai/usage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
527
apps/users/src/handlers/admin_ai.rs
Normal file
527
apps/users/src/handlers/admin_ai.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
407
apps/users/src/handlers/ai_auto.rs
Normal file
407
apps/users/src/handlers/ai_auto.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
166
crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql
Normal file
166
crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql
Normal 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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP INDEX IF EXISTS idx_ai_credit_packages_active;
|
||||
DROP TABLE IF EXISTS ai_credit_packages;
|
||||
|
|
@ -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);
|
||||
5
crates/db/src/models/ai/mod.rs
Normal file
5
crates/db/src/models/ai/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod models;
|
||||
pub mod repository;
|
||||
|
||||
pub use models::*;
|
||||
pub use repository::*;
|
||||
153
crates/db/src/models/ai/models.rs
Normal file
153
crates/db/src/models/ai/models.rs
Normal 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>,
|
||||
}
|
||||
614
crates/db/src/models/ai/repository.rs
Normal file
614
crates/db/src/models/ai/repository.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
408
docs/tracecoin-packages-reference.md
Normal file
408
docs/tracecoin-packages-reference.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
87
scripts/seed-tracecoin-packages.sql
Normal file
87
scripts/seed-tracecoin-packages.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue