Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
This commit is contained in:
@@ -93,6 +93,13 @@ pub struct CreateProductReq {
|
||||
pub price_value: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub metadata: Value,
|
||||
/// Entitlements catalog (migration 0014). Closed list of
|
||||
/// {slug, name, description} the operator declares the product
|
||||
/// offers. Policies must reference slugs from this catalog at
|
||||
/// write time. Omit / leave null to keep "free-text" mode where
|
||||
/// policies can carry any entitlement string.
|
||||
#[serde(default)]
|
||||
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
|
||||
}
|
||||
|
||||
/// Currencies the admin endpoints accept. Whitelist enforced here so
|
||||
@@ -189,6 +196,15 @@ pub async fn create_product(
|
||||
&metadata,
|
||||
)
|
||||
.await?;
|
||||
// Apply the entitlements catalog (if any) as a follow-up. Done
|
||||
// separately so the create_product_with_currency signature stays
|
||||
// narrow and the catalog edit path (set_product_entitlements_catalog)
|
||||
// is reused for both create + edit.
|
||||
let product = if let Some(catalog) = req.entitlements_catalog.as_deref() {
|
||||
repo::set_product_entitlements_catalog(&state.db, &product.id, Some(catalog)).await?
|
||||
} else {
|
||||
product
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
@@ -406,6 +422,27 @@ pub struct UpdateProductReq {
|
||||
pub price_currency: Option<String>,
|
||||
#[serde(default)]
|
||||
pub price_value: Option<i64>,
|
||||
/// Replace the entitlements catalog. `Some(vec)` sets it,
|
||||
/// `Some(empty vec)` clears it (drops back to free-text mode),
|
||||
/// omit / `None` to leave alone. Note: clearing is potentially
|
||||
/// destructive — existing policies that reference now-orphaned
|
||||
/// slugs keep working but new policies / edits will accept any
|
||||
/// string until the catalog is set again.
|
||||
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
|
||||
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
|
||||
}
|
||||
|
||||
/// Serde adapter — distinguishes "field omitted" (None) from
|
||||
/// "field supplied as null" (Some(None)) from "field supplied with
|
||||
/// value" (Some(Some(...))). Same nullable-patch shape used for
|
||||
/// price_sats_override on policies.
|
||||
fn deser_double_option_catalog<'de, D>(
|
||||
de: D,
|
||||
) -> Result<Option<Option<Vec<crate::models::EntitlementDef>>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<Vec<crate::models::EntitlementDef>>::deserialize(de).map(Some)
|
||||
}
|
||||
|
||||
pub async fn update_product(
|
||||
@@ -477,6 +514,18 @@ pub async fn update_product(
|
||||
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
|
||||
)
|
||||
.await?;
|
||||
// If the patch touched entitlements_catalog, apply it as a
|
||||
// separate UPDATE. Some(Some(vec)) sets, Some(Some(empty vec))
|
||||
// and Some(None) both clear (drop back to free-text mode).
|
||||
let updated = match &req.entitlements_catalog {
|
||||
Some(Some(catalog)) => {
|
||||
repo::set_product_entitlements_catalog(&state.db, &id, Some(catalog.as_slice())).await?
|
||||
}
|
||||
Some(None) => {
|
||||
repo::set_product_entitlements_catalog(&state.db, &id, None).await?
|
||||
}
|
||||
None => updated,
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
|
||||
Reference in New Issue
Block a user