feat(ai-credits): role-scoped purchasable credit packages
All checks were successful
build-and-release / build (catering-services) (push) Successful in 1m25s
build-and-release / build (cron) (push) Successful in 1m50s
build-and-release / build (customers) (push) Successful in 1m55s
build-and-release / build (companies) (push) Successful in 2m0s
build-and-release / build (developers) (push) Successful in 2m22s
build-and-release / build (employees) (push) Successful in 2m34s
build-and-release / build (fitness-trainers) (push) Successful in 1m41s
build-and-release / build (gateway) (push) Successful in 1m32s
build-and-release / build (graphic-designers) (push) Successful in 2m3s
build-and-release / build (job-seekers) (push) Successful in 2m4s
build-and-release / build (jobs) (push) Successful in 2m7s
build-and-release / build (payments) (push) Successful in 1m45s
build-and-release / build (makeup-artists) (push) Successful in 2m42s
build-and-release / build (tutors) (push) Successful in 1m42s
build-and-release / build (photographers) (push) Successful in 2m45s
backend-integration-tests / ai-credits (push) Successful in 50s
build-and-release / build (social-media-managers) (push) Successful in 2m39s
build-and-release / build (ugc-content-creators) (push) Successful in 2m40s
build-and-release / build (video-editors) (push) Successful in 2m42s
build-and-release / build (users) (push) Successful in 4m38s

Adds applicable_roles TEXT[] to ai_credit_packages (empty = visible to
every role, matching today's behavior for the 4 existing packages -
nothing changes for them until an admin opts them into specific
roles). Mirrors pricing_packages' existing role_key convention, as an
array since one AI package can reasonably apply to several roles at
once.

- GET /api/ai-credits (list_packages) now accepts optional auth (via a
  local MaybeAuthUser wrapper, since AuthUser doesn't implement axum's
  optional-extraction trait) and filters out packages not applicable
  to the viewer's roles. Anonymous viewers only see role-unrestricted
  packages.
- POST /api/ai-credits/order (create_order) re-validates role
  eligibility server-side too, not just in the listing - closes off
  a logged-in user buying a package never shown to them.
- Admin CRUD (GET/POST /api/admin/ai-credits/packages,
  PATCH .../{id}) now reads/writes applicable_roles.

Applied to nxtgauge_test and prod; verified column + index created,
existing 4 packages default to empty (all roles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-08-14 01:46:03 +05:30
parent 57146b2e19
commit 41b17baa14
3 changed files with 85 additions and 11 deletions

View file

@ -11,8 +11,8 @@
use crate::payu; use crate::payu;
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
extract::{Path, State}, extract::{FromRequestParts, Path, State},
http::StatusCode, http::{request::Parts, StatusCode},
routing::{get, patch, post}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
@ -22,6 +22,20 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
/// AuthUser doesn't implement axum's optional-extraction trait, so a bare
/// `Option<AuthUser>` handler param won't compile - this wraps it manually.
/// Used by list_packages, which needs to know the viewer's role when logged
/// in but must still work for anonymous/logged-out browsing.
struct MaybeAuthUser(Option<AuthUser>);
impl<S: Send + Sync> FromRequestParts<S> for MaybeAuthUser {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Ok(MaybeAuthUser(AuthUser::from_request_parts(parts, state).await.ok()))
}
}
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_packages)) .route("/", get(list_packages))
@ -47,6 +61,7 @@ struct AiCreditPackageRow {
description: Option<String>, description: Option<String>,
credits: i32, credits: i32,
price_inr: i32, price_inr: i32,
applicable_roles: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -54,14 +69,36 @@ struct ListPackagesResponse {
packages: Vec<AiCreditPackageRow>, packages: Vec<AiCreditPackageRow>,
} }
async fn list_packages(State(state): State<AppState>) -> Result<Json<ListPackagesResponse>, (StatusCode, String)> { /// A package with an empty applicable_roles is visible to everyone (the
/// default, matching pre-role-scoping behavior); otherwise the viewer must
/// hold at least one of the listed roles. Logged-out/anonymous viewers only
/// ever see role-unrestricted packages.
fn package_visible_to(applicable_roles: &[String], viewer_roles: Option<&[String]>) -> bool {
if applicable_roles.is_empty() {
return true;
}
viewer_roles
.map(|roles| roles.iter().any(|r| applicable_roles.iter().any(|ar| ar.eq_ignore_ascii_case(r))))
.unwrap_or(false)
}
async fn list_packages(
MaybeAuthUser(auth): MaybeAuthUser,
State(state): State<AppState>,
) -> Result<Json<ListPackagesResponse>, (StatusCode, String)> {
let packages = sqlx::query_as::<_, AiCreditPackageRow>( let packages = sqlx::query_as::<_, AiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE is_active = TRUE ORDER BY price_inr", "SELECT id, name, description, credits, price_inr, applicable_roles FROM ai_credit_packages WHERE is_active = TRUE ORDER BY price_inr",
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let viewer_roles = auth.as_ref().map(|a| a.claims.roles.as_slice());
let packages = packages
.into_iter()
.filter(|p| package_visible_to(&p.applicable_roles, viewer_roles))
.collect();
Ok(Json(ListPackagesResponse { packages })) Ok(Json(ListPackagesResponse { packages }))
} }
@ -95,6 +132,7 @@ struct AiCreditPackagePriceRow {
name: String, name: String,
credits: i32, credits: i32,
price_inr: i32, price_inr: i32,
applicable_roles: Vec<String>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
@ -191,7 +229,7 @@ async fn create_order(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?; .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;
let package = sqlx::query_as::<_, AiCreditPackagePriceRow>( let package = sqlx::query_as::<_, AiCreditPackagePriceRow>(
"SELECT name, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = TRUE", "SELECT name, credits, price_inr, applicable_roles FROM ai_credit_packages WHERE id = $1 AND is_active = TRUE",
) )
.bind(package_id) .bind(package_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -199,6 +237,12 @@ async fn create_order(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive AI credit package".to_string()))?; .ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive AI credit package".to_string()))?;
// Enforce role scoping server-side too, not just in the listing - a
// logged-in user could otherwise buy a package never shown to them.
if !package_visible_to(&package.applicable_roles, Some(auth.claims.roles.as_slice())) {
return Err((StatusCode::FORBIDDEN, "This package is not available for your role".to_string()));
}
let contact = sqlx::query_as::<_, UserContactRow>("SELECT email, full_name, phone FROM users WHERE id = $1") let contact = sqlx::query_as::<_, UserContactRow>("SELECT email, full_name, phone FROM users WHERE id = $1")
.bind(auth.user_id) .bind(auth.user_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -433,6 +477,7 @@ struct AdminAiCreditPackageRow {
credits: i32, credits: i32,
price_inr: i32, price_inr: i32,
is_active: bool, is_active: bool,
applicable_roles: Vec<String>,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
} }
@ -449,7 +494,7 @@ async fn admin_list_packages(
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let packages = sqlx::query_as::<_, AdminAiCreditPackageRow>( let packages = sqlx::query_as::<_, AdminAiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr, is_active, created_at, updated_at FROM ai_credit_packages ORDER BY price_inr", "SELECT id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at FROM ai_credit_packages ORDER BY price_inr",
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
@ -464,6 +509,8 @@ struct CreatePackageRequest {
description: Option<String>, description: Option<String>,
credits: i32, credits: i32,
price_inr: i32, price_inr: i32,
#[serde(default)]
applicable_roles: Vec<String>,
} }
async fn admin_create_package( async fn admin_create_package(
@ -479,15 +526,16 @@ async fn admin_create_package(
let package = sqlx::query_as::<_, AdminAiCreditPackageRow>( let package = sqlx::query_as::<_, AdminAiCreditPackageRow>(
r#" r#"
INSERT INTO ai_credit_packages (name, description, credits, price_inr) INSERT INTO ai_credit_packages (name, description, credits, price_inr, applicable_roles)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at
"#, "#,
) )
.bind(&body.name) .bind(&body.name)
.bind(&body.description) .bind(&body.description)
.bind(body.credits) .bind(body.credits)
.bind(body.price_inr) .bind(body.price_inr)
.bind(&body.applicable_roles)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -502,6 +550,7 @@ struct UpdatePackageRequest {
credits: Option<i32>, credits: Option<i32>,
price_inr: Option<i32>, price_inr: Option<i32>,
is_active: Option<bool>, is_active: Option<bool>,
applicable_roles: Option<Vec<String>>,
} }
async fn admin_update_package( async fn admin_update_package(
@ -524,9 +573,10 @@ async fn admin_update_package(
credits = COALESCE($3, credits), credits = COALESCE($3, credits),
price_inr = COALESCE($4, price_inr), price_inr = COALESCE($4, price_inr),
is_active = COALESCE($5, is_active), is_active = COALESCE($5, is_active),
applicable_roles = COALESCE($6, applicable_roles),
updated_at = NOW() updated_at = NOW()
WHERE id = $6 WHERE id = $7
RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at
"#, "#,
) )
.bind(&body.name) .bind(&body.name)
@ -534,6 +584,7 @@ async fn admin_update_package(
.bind(body.credits) .bind(body.credits)
.bind(body.price_inr) .bind(body.price_inr)
.bind(body.is_active) .bind(body.is_active)
.bind(&body.applicable_roles)
.bind(id) .bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await .await

View file

@ -0,0 +1,6 @@
BEGIN;
DROP INDEX IF EXISTS idx_ai_credit_packages_applicable_roles;
ALTER TABLE ai_credit_packages DROP COLUMN IF EXISTS applicable_roles;
COMMIT;

View file

@ -0,0 +1,17 @@
-- Role-scoped AI credit packages, mirroring pricing_packages' existing
-- role_key convention (though as an array here since one AI package can
-- reasonably apply to several roles at once, unlike TraceCoin packages
-- which are one row per role). Empty array = visible/purchasable by every
-- role (the default, matching today's actual behavior for the 4 existing
-- packages so nothing changes for them until an admin opts them into
-- specific roles).
BEGIN;
ALTER TABLE ai_credit_packages
ADD COLUMN IF NOT EXISTS applicable_roles TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_ai_credit_packages_applicable_roles
ON ai_credit_packages USING GIN (applicable_roles);
COMMIT;