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
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:
parent
57146b2e19
commit
41b17baa14
3 changed files with 85 additions and 11 deletions
|
|
@ -11,8 +11,8 @@
|
|||
use crate::payu;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
extract::{FromRequestParts, Path, State},
|
||||
http::{request::Parts, StatusCode},
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
|
@ -22,6 +22,20 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::{FromRow, PgPool};
|
||||
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> {
|
||||
Router::new()
|
||||
.route("/", get(list_packages))
|
||||
|
|
@ -47,6 +61,7 @@ struct AiCreditPackageRow {
|
|||
description: Option<String>,
|
||||
credits: i32,
|
||||
price_inr: i32,
|
||||
applicable_roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -54,14 +69,36 @@ struct ListPackagesResponse {
|
|||
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>(
|
||||
"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)
|
||||
.await
|
||||
.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 }))
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +132,7 @@ struct AiCreditPackagePriceRow {
|
|||
name: String,
|
||||
credits: i32,
|
||||
price_inr: i32,
|
||||
applicable_roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
|
|
@ -191,7 +229,7 @@ async fn create_order(
|
|||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;
|
||||
|
||||
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)
|
||||
.fetch_optional(&state.pool)
|
||||
|
|
@ -199,6 +237,12 @@ async fn create_order(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||
.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")
|
||||
.bind(auth.user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
|
|
@ -433,6 +477,7 @@ struct AdminAiCreditPackageRow {
|
|||
credits: i32,
|
||||
price_inr: i32,
|
||||
is_active: bool,
|
||||
applicable_roles: Vec<String>,
|
||||
created_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()))?;
|
||||
|
||||
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)
|
||||
.await
|
||||
|
|
@ -464,6 +509,8 @@ struct CreatePackageRequest {
|
|||
description: Option<String>,
|
||||
credits: i32,
|
||||
price_inr: i32,
|
||||
#[serde(default)]
|
||||
applicable_roles: Vec<String>,
|
||||
}
|
||||
|
||||
async fn admin_create_package(
|
||||
|
|
@ -479,15 +526,16 @@ async fn admin_create_package(
|
|||
|
||||
let package = sqlx::query_as::<_, AdminAiCreditPackageRow>(
|
||||
r#"
|
||||
INSERT INTO ai_credit_packages (name, description, credits, price_inr)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at
|
||||
INSERT INTO ai_credit_packages (name, description, credits, price_inr, applicable_roles)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(&body.name)
|
||||
.bind(&body.description)
|
||||
.bind(body.credits)
|
||||
.bind(body.price_inr)
|
||||
.bind(&body.applicable_roles)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||
|
|
@ -502,6 +550,7 @@ struct UpdatePackageRequest {
|
|||
credits: Option<i32>,
|
||||
price_inr: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
applicable_roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
async fn admin_update_package(
|
||||
|
|
@ -524,9 +573,10 @@ async fn admin_update_package(
|
|||
credits = COALESCE($3, credits),
|
||||
price_inr = COALESCE($4, price_inr),
|
||||
is_active = COALESCE($5, is_active),
|
||||
applicable_roles = COALESCE($6, applicable_roles),
|
||||
updated_at = NOW()
|
||||
WHERE id = $6
|
||||
RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at
|
||||
WHERE id = $7
|
||||
RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(&body.name)
|
||||
|
|
@ -534,6 +584,7 @@ async fn admin_update_package(
|
|||
.bind(body.credits)
|
||||
.bind(body.price_inr)
|
||||
.bind(body.is_active)
|
||||
.bind(&body.applicable_roles)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue