From c85e6af22e4b3aa05d926b9209e26cba6a5bcf85 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Mon, 15 Jun 2026 06:15:49 +0530 Subject: [PATCH] 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 --- Cargo.lock | 22 +- Dockerfile.base | 2 +- Dockerfile.fast | 2 +- Dockerfile.migrate | 2 +- Dockerfile.optimized | 13 +- Dockerfile.superfast | 4 +- Dockerfile.template | 4 +- Dockerfile.ultrafast | 2 +- Dockerfile.working | 40 ++ apps/catering_services/Dockerfile | 4 +- apps/companies/Dockerfile | 4 +- apps/cron/Dockerfile | 4 +- apps/cron/src/main.rs | 16 + apps/cron/src/tasks/ai.rs | 40 ++ apps/cron/src/tasks/mod.rs | 1 + apps/customers/Dockerfile | 4 +- apps/developers/Dockerfile | 4 +- apps/employees/Dockerfile | 4 +- apps/fitness_trainers/Dockerfile | 4 +- apps/gateway/Dockerfile | 4 +- apps/graphic_designers/Dockerfile | 4 +- apps/job_seekers/Dockerfile | 4 +- apps/jobs/Dockerfile | 4 +- apps/leads/Dockerfile | 4 +- apps/makeup_artists/Dockerfile | 4 +- apps/payments/Dockerfile | 4 +- apps/payments/src/ai_credits.rs | 374 +++++++++++ apps/payments/src/main.rs | 2 + apps/photographers/Dockerfile | 4 +- apps/social_media_managers/Dockerfile | 4 +- apps/tutors/Dockerfile | 4 +- apps/ugc_content_creators/Dockerfile | 4 +- apps/users/Cargo.toml | 3 + apps/users/Dockerfile | 4 +- apps/users/src/ai/credits.rs | 154 +++++ apps/users/src/ai/litellm.rs | 215 ++++++ apps/users/src/ai/middleware.rs | 164 +++++ apps/users/src/ai/mod.rs | 7 + apps/users/src/ai/model_router.rs | 86 +++ apps/users/src/ai/orchestrator.rs | 303 +++++++++ apps/users/src/ai/plans.rs | 183 +++++ apps/users/src/ai/usage.rs | 40 ++ apps/users/src/handlers/admin_ai.rs | 527 +++++++++++++++ apps/users/src/handlers/ai.rs | 625 +++++++++++++----- apps/users/src/handlers/ai_auto.rs | 407 ++++++++++++ apps/users/src/handlers/mod.rs | 2 + apps/users/src/main.rs | 11 +- apps/video_editors/Dockerfile | 4 +- ...0260614233620_ai_plans_and_limits.down.sql | 9 + .../20260614233620_ai_plans_and_limits.up.sql | 166 +++++ ...20260615060600_ai_credit_packages.down.sql | 2 + .../20260615060600_ai_credit_packages.up.sql | 18 + crates/db/src/models/ai/mod.rs | 5 + crates/db/src/models/ai/models.rs | 153 +++++ crates/db/src/models/ai/repository.rs | 614 +++++++++++++++++ crates/db/src/models/mod.rs | 49 +- docs/tracecoin-packages-reference.md | 408 ++++++++++++ scripts/bootstrap-live-compat.sql | 6 +- scripts/build-base-image.sh | 15 +- scripts/fast-build.sh | 4 +- scripts/seed-tracecoin-packages.sql | 87 +++ 61 files changed, 4622 insertions(+), 245 deletions(-) create mode 100644 Dockerfile.working create mode 100644 apps/cron/src/tasks/ai.rs create mode 100644 apps/payments/src/ai_credits.rs create mode 100644 apps/users/src/ai/credits.rs create mode 100644 apps/users/src/ai/litellm.rs create mode 100644 apps/users/src/ai/middleware.rs create mode 100644 apps/users/src/ai/mod.rs create mode 100644 apps/users/src/ai/model_router.rs create mode 100644 apps/users/src/ai/orchestrator.rs create mode 100644 apps/users/src/ai/plans.rs create mode 100644 apps/users/src/ai/usage.rs create mode 100644 apps/users/src/handlers/admin_ai.rs create mode 100644 apps/users/src/handlers/ai_auto.rs create mode 100644 crates/db/migrations/20260614233620_ai_plans_and_limits.down.sql create mode 100644 crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql create mode 100644 crates/db/migrations/20260615060600_ai_credit_packages.down.sql create mode 100644 crates/db/migrations/20260615060600_ai_credit_packages.up.sql create mode 100644 crates/db/src/models/ai/mod.rs create mode 100644 crates/db/src/models/ai/models.rs create mode 100644 crates/db/src/models/ai/repository.rs create mode 100644 docs/tracecoin-packages-reference.md create mode 100644 scripts/seed-tracecoin-packages.sql diff --git a/Cargo.lock b/Cargo.lock index e5a4483..17fd0e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Dockerfile.base b/Dockerfile.base index 76a448d..93a4ef1 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -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 diff --git a/Dockerfile.fast b/Dockerfile.fast index b87f49d..bda5753 100644 --- a/Dockerfile.fast +++ b/Dockerfile.fast @@ -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 diff --git a/Dockerfile.migrate b/Dockerfile.migrate index 5158398..f228fe6 100644 --- a/Dockerfile.migrate +++ b/Dockerfile.migrate @@ -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/ diff --git a/Dockerfile.optimized b/Dockerfile.optimized index 17a7c5a..6d4da46 100644 --- a/Dockerfile.optimized +++ b/Dockerfile.optimized @@ -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 diff --git a/Dockerfile.superfast b/Dockerfile.superfast index 3b9165d..6107cda 100644 --- a/Dockerfile.superfast +++ b/Dockerfile.superfast @@ -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 diff --git a/Dockerfile.template b/Dockerfile.template index dd5c543..1c9b816 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -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 diff --git a/Dockerfile.ultrafast b/Dockerfile.ultrafast index 62e5e6b..bd7aaa8 100644 --- a/Dockerfile.ultrafast +++ b/Dockerfile.ultrafast @@ -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 diff --git a/Dockerfile.working b/Dockerfile.working new file mode 100644 index 0000000..93723af --- /dev/null +++ b/Dockerfile.working @@ -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"] diff --git a/apps/catering_services/Dockerfile b/apps/catering_services/Dockerfile index 420c51e..a22ff5a 100644 --- a/apps/catering_services/Dockerfile +++ b/apps/catering_services/Dockerfile @@ -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 diff --git a/apps/companies/Dockerfile b/apps/companies/Dockerfile index baae1b9..57ea0c6 100644 --- a/apps/companies/Dockerfile +++ b/apps/companies/Dockerfile @@ -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 diff --git a/apps/cron/Dockerfile b/apps/cron/Dockerfile index 468ae5d..72c23c5 100644 --- a/apps/cron/Dockerfile +++ b/apps/cron/Dockerfile @@ -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 diff --git a/apps/cron/src/main.rs b/apps/cron/src/main.rs index 752bd0a..90a7c9a 100644 --- a/apps/cron/src/main.rs +++ b/apps/cron/src/main.rs @@ -75,6 +75,22 @@ async fn main() -> Result<(), Box> { } }); + // 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."); diff --git a/apps/cron/src/tasks/ai.rs b/apps/cron/src/tasks/ai.rs new file mode 100644 index 0000000..a6dfde4 --- /dev/null +++ b/apps/cron/src/tasks/ai.rs @@ -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(()) +} diff --git a/apps/cron/src/tasks/mod.rs b/apps/cron/src/tasks/mod.rs index a48bd41..97fa2cf 100644 --- a/apps/cron/src/tasks/mod.rs +++ b/apps/cron/src/tasks/mod.rs @@ -1,3 +1,4 @@ +pub mod ai; pub mod leads; pub mod requirements; pub mod jobs; diff --git a/apps/customers/Dockerfile b/apps/customers/Dockerfile index 2761fa1..0c1c9af 100644 --- a/apps/customers/Dockerfile +++ b/apps/customers/Dockerfile @@ -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 diff --git a/apps/developers/Dockerfile b/apps/developers/Dockerfile index 5cfd164..31a5932 100644 --- a/apps/developers/Dockerfile +++ b/apps/developers/Dockerfile @@ -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 diff --git a/apps/employees/Dockerfile b/apps/employees/Dockerfile index ecf80a9..04e1915 100644 --- a/apps/employees/Dockerfile +++ b/apps/employees/Dockerfile @@ -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 diff --git a/apps/fitness_trainers/Dockerfile b/apps/fitness_trainers/Dockerfile index d1d8984..5cdb753 100644 --- a/apps/fitness_trainers/Dockerfile +++ b/apps/fitness_trainers/Dockerfile @@ -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 diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 8ef7616..f5c5e69 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -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 diff --git a/apps/graphic_designers/Dockerfile b/apps/graphic_designers/Dockerfile index a012c2f..dc191dc 100644 --- a/apps/graphic_designers/Dockerfile +++ b/apps/graphic_designers/Dockerfile @@ -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 diff --git a/apps/job_seekers/Dockerfile b/apps/job_seekers/Dockerfile index 097ec6c..0a9ef5c 100644 --- a/apps/job_seekers/Dockerfile +++ b/apps/job_seekers/Dockerfile @@ -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 diff --git a/apps/jobs/Dockerfile b/apps/jobs/Dockerfile index 282d968..70c4d42 100644 --- a/apps/jobs/Dockerfile +++ b/apps/jobs/Dockerfile @@ -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 diff --git a/apps/leads/Dockerfile b/apps/leads/Dockerfile index 9009c94..5f7a218 100644 --- a/apps/leads/Dockerfile +++ b/apps/leads/Dockerfile @@ -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 diff --git a/apps/makeup_artists/Dockerfile b/apps/makeup_artists/Dockerfile index acc2842..95c0332 100644 --- a/apps/makeup_artists/Dockerfile +++ b/apps/makeup_artists/Dockerfile @@ -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 diff --git a/apps/payments/Dockerfile b/apps/payments/Dockerfile index bd39185..25efad7 100644 --- a/apps/payments/Dockerfile +++ b/apps/payments/Dockerfile @@ -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 diff --git a/apps/payments/src/ai_credits.rs b/apps/payments/src/ai_credits.rs new file mode 100644 index 0000000..b2e633a --- /dev/null +++ b/apps/payments/src/ai_credits.rs @@ -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, +} + +#[derive(Debug, Serialize, FromRow)] +struct AiCreditPackageRow { + id: Uuid, + name: String, + description: Option, + credits: i32, + price_inr: i32, +} + +pub fn ai_credits_router() -> Router { + 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) -> 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, + Json(payload): Json, +) -> 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, + Json(payload): Json, +) -> 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() +} diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 8908333..b74d9cf 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -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") diff --git a/apps/photographers/Dockerfile b/apps/photographers/Dockerfile index 104130f..a55e5f2 100644 --- a/apps/photographers/Dockerfile +++ b/apps/photographers/Dockerfile @@ -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 diff --git a/apps/social_media_managers/Dockerfile b/apps/social_media_managers/Dockerfile index 1e9ff3a..bdcd6c1 100644 --- a/apps/social_media_managers/Dockerfile +++ b/apps/social_media_managers/Dockerfile @@ -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 diff --git a/apps/tutors/Dockerfile b/apps/tutors/Dockerfile index 8967d4e..3dc99a1 100644 --- a/apps/tutors/Dockerfile +++ b/apps/tutors/Dockerfile @@ -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 diff --git a/apps/ugc_content_creators/Dockerfile b/apps/ugc_content_creators/Dockerfile index e43b599..1f32a31 100644 --- a/apps/ugc_content_creators/Dockerfile +++ b/apps/ugc_content_creators/Dockerfile @@ -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 diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml index ec6857a..4a45163 100644 --- a/apps/users/Cargo.toml +++ b/apps/users/Cargo.toml @@ -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" diff --git a/apps/users/Dockerfile b/apps/users/Dockerfile index 37c8b77..7f380bd 100644 --- a/apps/users/Dockerfile +++ b/apps/users/Dockerfile @@ -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 diff --git a/apps/users/src/ai/credits.rs b/apps/users/src/ai/credits.rs new file mode 100644 index 0000000..64fc2ee --- /dev/null +++ b/apps/users/src/ai/credits.rs @@ -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 { + 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 { + 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 { + 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) +} diff --git a/apps/users/src/ai/litellm.rs b/apps/users/src/ai/litellm.rs new file mode 100644 index 0000000..ab3f463 --- /dev/null +++ b/apps/users/src/ai/litellm.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiteLlmUsage { + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiteLlmChoice { + pub index: Option, + pub message: Option, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiteLlmChatResponse { + pub id: Option, + pub model: Option, + pub choices: Vec, + pub usage: Option, +} + +#[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, +} + +impl LiteLlmClient { + pub fn new() -> Result { + 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) -> Self { + Self { + client: Client::new(), + base_url, + api_key, + } + } + + pub async fn chat_completion( + &self, + request: LiteLlmChatRequest, + ) -> Result { + 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::().await?; + Ok(result) + } + + pub async fn chat_completion_text( + &self, + model: &str, + system_prompt: Option<&str>, + user_message: &str, + max_tokens: Option, + ) -> Result<(String, LiteLlmUsage, Option), 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 { + 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::().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) + }) + } +} diff --git a/apps/users/src/ai/middleware.rs b/apps/users/src/ai/middleware.rs new file mode 100644 index 0000000..25cf931 --- /dev/null +++ b/apps/users/src/ai/middleware.rs @@ -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, + 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 FromRequestParts for AiAccess +where + S: Send + Sync, +{ + type Rejection = AiAccessError; + + fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> impl Future> + 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::( + 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 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 { + 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::() + .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) +} diff --git a/apps/users/src/ai/mod.rs b/apps/users/src/ai/mod.rs new file mode 100644 index 0000000..fcad84b --- /dev/null +++ b/apps/users/src/ai/mod.rs @@ -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; diff --git a/apps/users/src/ai/model_router.rs b/apps/users/src/ai/model_router.rs new file mode 100644 index 0000000..3ea4001 --- /dev/null +++ b/apps/users/src/ai/model_router.rs @@ -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 { + 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 { + let preferred = requested + .and_then(|m| m.parse::().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 { + let requested = if force_main { + Some(ASKASH_MAIN) + } else { + Some(preferred_tier_for_feature(&feature.feature_code).alias()) + }; + resolve_model(feature, plan, requested) +} diff --git a/apps/users/src/ai/orchestrator.rs b/apps/users/src/ai/orchestrator.rs new file mode 100644 index 0000000..e341c99 --- /dev/null +++ b/apps/users/src/ai/orchestrator.rs @@ -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, + pub output_tokens: Option, + pub total_tokens: Option, + pub request_id: Option, + 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, +) -> Result { + 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, +) -> Result { + 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; +} diff --git a/apps/users/src/ai/plans.rs b/apps/users/src/ai/plans.rs new file mode 100644 index 0000000..034932d --- /dev/null +++ b/apps/users/src/ai/plans.rs @@ -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) -> (DateTime, DateTime) { + 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) +} diff --git a/apps/users/src/ai/usage.rs b/apps/users/src/ai/usage.rs new file mode 100644 index 0000000..a489da7 --- /dev/null +++ b/apps/users/src/ai/usage.rs @@ -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, + output_tokens: Option, + total_tokens: Option, + 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); + } +} diff --git a/apps/users/src/handlers/admin_ai.rs b/apps/users/src/handlers/admin_ai.rs new file mode 100644 index 0000000..5e1d9a8 --- /dev/null +++ b/apps/users/src/handlers/admin_ai.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct AbuseCheckBody { + text: String, + #[serde(default)] + model: Option, +} + +#[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, +} + +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 { + 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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 { + 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Path(user_id): Path, + Query(q): Query, +) -> 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, + auth: AuthUser, + Path(user_id): Path, + Query(q): Query, +) -> 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, 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, 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() + } + } +} diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 7e859b4..7de6dc5 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -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 = 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, Json(body): Json, @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_credits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, +} + +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 = 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 = 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, /// Optional user id (fallback for unauthenticated context; auth user wins if both present) pub user_id: Option, + /// Optional model override (askash-fast or askash-main) + pub model: Option, } #[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 { .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); diff --git a/apps/users/src/handlers/ai_auto.rs b/apps/users/src/handlers/ai_auto.rs new file mode 100644 index 0000000..09a4d22 --- /dev/null +++ b/apps/users/src/handlers/ai_auto.rs @@ -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, + #[serde(default)] + preferred_titles: Option, + #[serde(default)] + preferred_locations: Option, + #[serde(default)] + preferred_job_types: Option, + #[serde(default)] + preferred_work_modes: Option, + #[serde(default)] + preferred_skills: Option, + #[serde(default)] + min_salary: Option, + #[serde(default)] + max_salary: Option, + #[serde(default)] + max_applications_per_day: Option, + #[serde(default)] + require_user_approval: Option, +} + +#[derive(Debug, Deserialize)] +struct AutoRequestSettingsBody { + #[serde(default)] + professional_role_code: Option, + #[serde(default)] + is_enabled: Option, + #[serde(default)] + preferred_categories: Option, + #[serde(default)] + preferred_locations: Option, + #[serde(default)] + preferred_requirement_types: Option, + #[serde(default)] + min_budget: Option, + #[serde(default)] + max_budget: Option, + #[serde(default)] + max_requests_per_day: Option, + #[serde(default)] + require_user_approval: Option, +} + +#[derive(Debug, Deserialize)] +struct AutoSuggestBody { + text: String, + #[serde(default)] + model: Option, +} + +#[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 { + 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, + 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Json(body): Json, +) -> 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, + auth: AuthUser, + Query(params): Query, +) -> 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, + auth: AuthUser, + Query(params): Query, +) -> 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() + } + } +} diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 694c0f6..dc282c0 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -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; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index ce8dc43..38806ca 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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); diff --git a/apps/video_editors/Dockerfile b/apps/video_editors/Dockerfile index cf655b0..0a94075 100644 --- a/apps/video_editors/Dockerfile +++ b/apps/video_editors/Dockerfile @@ -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 diff --git a/crates/db/migrations/20260614233620_ai_plans_and_limits.down.sql b/crates/db/migrations/20260614233620_ai_plans_and_limits.down.sql new file mode 100644 index 0000000..720bf89 --- /dev/null +++ b/crates/db/migrations/20260614233620_ai_plans_and_limits.down.sql @@ -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; diff --git a/crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql b/crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql new file mode 100644 index 0000000..ab7f30a --- /dev/null +++ b/crates/db/migrations/20260614233620_ai_plans_and_limits.up.sql @@ -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); diff --git a/crates/db/migrations/20260615060600_ai_credit_packages.down.sql b/crates/db/migrations/20260615060600_ai_credit_packages.down.sql new file mode 100644 index 0000000..8b3093b --- /dev/null +++ b/crates/db/migrations/20260615060600_ai_credit_packages.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_ai_credit_packages_active; +DROP TABLE IF EXISTS ai_credit_packages; diff --git a/crates/db/migrations/20260615060600_ai_credit_packages.up.sql b/crates/db/migrations/20260615060600_ai_credit_packages.up.sql new file mode 100644 index 0000000..56f2e28 --- /dev/null +++ b/crates/db/migrations/20260615060600_ai_credit_packages.up.sql @@ -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); diff --git a/crates/db/src/models/ai/mod.rs b/crates/db/src/models/ai/mod.rs new file mode 100644 index 0000000..334d8aa --- /dev/null +++ b/crates/db/src/models/ai/mod.rs @@ -0,0 +1,5 @@ +pub mod models; +pub mod repository; + +pub use models::*; +pub use repository::*; diff --git a/crates/db/src/models/ai/models.rs b/crates/db/src/models/ai/models.rs new file mode 100644 index 0000000..6303f09 --- /dev/null +++ b/crates/db/src/models/ai/models.rs @@ -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, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct UserAiSubscription { + pub id: Uuid, + pub user_id: Uuid, + pub plan_id: Uuid, + pub role_code: Option, + 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, + pub current_period_end: DateTime, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[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, + pub max_output_tokens: Option, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AiUsageLog { + pub id: Uuid, + pub user_id: Uuid, + pub role_code: Option, + pub feature_code: String, + pub model_alias: String, + pub credits_charged: i32, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub status: String, + pub request_id: Option, + pub error_message: Option, + pub created_at: DateTime, +} + +#[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, + pub description: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AiAutoApplySettings { + pub id: Uuid, + pub user_id: Uuid, + pub is_enabled: bool, + pub preferred_titles: Option, + pub preferred_locations: Option, + pub preferred_job_types: Option, + pub preferred_work_modes: Option, + pub preferred_skills: Option, + pub min_salary: Option, + pub max_salary: Option, + pub max_applications_per_day: i32, + pub require_user_approval: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AiCreditPackage { + pub id: Uuid, + pub name: String, + pub description: Option, + pub credits: i32, + pub price_inr: i32, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AiAutoApplyLog { + pub id: Uuid, + pub user_id: Uuid, + pub job_id: Uuid, + pub match_score: Option, + pub status: String, + pub credits_charged: i32, + pub generated_cover_letter: Option, + pub applied_at: Option>, + pub created_at: DateTime, +} + +#[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, + pub preferred_locations: Option, + pub preferred_requirement_types: Option, + pub min_budget: Option, + pub max_budget: Option, + pub max_requests_per_day: i32, + pub require_user_approval: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[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, + pub status: String, + pub credits_charged: i32, + pub requested_at: Option>, + pub created_at: DateTime, +} diff --git a/crates/db/src/models/ai/repository.rs b/crates/db/src/models/ai/repository.rs new file mode 100644 index 0000000..955afdd --- /dev/null +++ b/crates/db/src/models/ai/repository.rs @@ -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, 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, 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, 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 { + 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, + daily_action_limit: Option, + allowed_models: Option, + allowed_features: Option, + is_active: Option, + ) -> Result { + 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, 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, + period_end: DateTime, + ) -> Result { + 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, + period_end: DateTime, + ) -> Result { + 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, 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, 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, + max_output_tokens: Option, + ) -> Result { + 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, + max_input_tokens: Option, + max_output_tokens: Option, + is_active: Option, + ) -> Result { + 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, 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, 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, + output_tokens: Option, + total_tokens: Option, + 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, 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, + 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, 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, 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, + preferred_locations: Option, + preferred_job_types: Option, + preferred_work_modes: Option, + preferred_skills: Option, + min_salary: Option, + max_salary: Option, + max_applications_per_day: i32, + require_user_approval: bool, + ) -> Result { + 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, 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, + preferred_locations: Option, + preferred_requirement_types: Option, + min_budget: Option, + max_budget: Option, + max_requests_per_day: i32, + require_user_approval: bool, + ) -> Result { + 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, 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, 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 + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index b8ae833..6b7ecd4 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -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; diff --git a/docs/tracecoin-packages-reference.md b/docs/tracecoin-packages-reference.md new file mode 100644 index 0000000..4e7f67c --- /dev/null +++ b/docs/tracecoin-packages-reference.md @@ -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. diff --git a/scripts/bootstrap-live-compat.sql b/scripts/bootstrap-live-compat.sql index 612293c..239d479 100644 --- a/scripts/bootstrap-live-compat.sql +++ b/scripts/bootstrap-live-compat.sql @@ -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; diff --git a/scripts/build-base-image.sh b/scripts/build-base-image.sh index 5583cde..6597e73 100755 --- a/scripts/build-base-image.sh +++ b/scripts/build-base-image.sh @@ -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" diff --git a/scripts/fast-build.sh b/scripts/fast-build.sh index 8990e2f..71d2b76 100755 --- a/scripts/fast-build.sh +++ b/scripts/fast-build.sh @@ -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 diff --git a/scripts/seed-tracecoin-packages.sql b/scripts/seed-tracecoin-packages.sql new file mode 100644 index 0000000..e543ba1 --- /dev/null +++ b/scripts/seed-tracecoin-packages.sql @@ -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;