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:
@@ -0,0 +1,86 @@
|
||||
-- Product-level entitlements catalog.
|
||||
--
|
||||
-- Before this migration, entitlements were free-text strings stored
|
||||
-- per-policy as a JSON array. There was no shared vocabulary across
|
||||
-- the policies of a single product, no display-name metadata, and no
|
||||
-- way for the buy page to render anything but the raw slug
|
||||
-- (`ai_summaries` instead of "AI summaries").
|
||||
--
|
||||
-- This migration introduces a per-product catalog: each product
|
||||
-- declares the entitlements it offers, with slug + display name +
|
||||
-- optional description. Policies still store entitlements as a JSON
|
||||
-- array of slugs (no schema change to that side); the catalog is the
|
||||
-- source of truth for the human-readable rendering and the
|
||||
-- closed-list validation that policy entitlements must reference
|
||||
-- catalog entries.
|
||||
--
|
||||
-- Strategy: additive only. The new column is nullable. Existing
|
||||
-- products are auto-backfilled with a catalog derived from the union
|
||||
-- of entitlements across their existing policies — operator can edit
|
||||
-- afterward to add display names + descriptions. Products with zero
|
||||
-- existing policy entitlements get NULL (legacy "free-text" mode
|
||||
-- continues to work; operator opts in by editing the product).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- products: entitlements_catalog_json
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Stored shape (when non-null):
|
||||
-- [
|
||||
-- { "slug": "core", "name": "Core", "description": "..." },
|
||||
-- { "slug": "ai_summaries", "name": "AI summaries", "description": "..." },
|
||||
-- ...
|
||||
-- ]
|
||||
--
|
||||
-- Nullable so existing products that don't want a catalog stay clean,
|
||||
-- and so operators can opt out by clearing the field.
|
||||
ALTER TABLE products ADD COLUMN entitlements_catalog_json TEXT;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Backfill: derive an initial catalog from existing policy entitlements
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- For each product, collect the union of distinct entitlement slugs
|
||||
-- across all its policies. Build a catalog row for each:
|
||||
-- - slug = the slug as found in policies.entitlements_json
|
||||
-- - name = slug with underscores replaced by spaces (so "ai_summaries"
|
||||
-- becomes "ai summaries"; operator can title-case via the
|
||||
-- admin UI)
|
||||
-- - description = empty string (operator fills in)
|
||||
--
|
||||
-- Products with NO entitlements anywhere across their policies get
|
||||
-- left with NULL — they're presumed not to use entitlements yet, and
|
||||
-- forcing an empty catalog on them just adds a row to delete later.
|
||||
UPDATE products SET entitlements_catalog_json = (
|
||||
SELECT json_group_array(
|
||||
json_object(
|
||||
'slug', uniq_slug,
|
||||
'name', replace(uniq_slug, '_', ' '),
|
||||
'description', ''
|
||||
)
|
||||
)
|
||||
FROM (
|
||||
SELECT DISTINCT je.value AS uniq_slug
|
||||
FROM policies p, json_each(p.entitlements_json) je
|
||||
WHERE p.product_id = products.id
|
||||
)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM policies p, json_each(p.entitlements_json) je
|
||||
WHERE p.product_id = products.id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Note: no CHECK constraint enforcing that policy entitlements
|
||||
-- reference catalog slugs. SQLite doesn't easily express this kind of
|
||||
-- cross-row JSON validation as a CHECK, and even if it did, the
|
||||
-- validation needs to be conditional on the catalog being non-NULL
|
||||
-- (legacy mode = no catalog = anything goes). The API layer enforces
|
||||
-- the closed-list rule at write time:
|
||||
--
|
||||
-- - On policy create/update: if the parent product has a non-NULL,
|
||||
-- non-empty catalog, every entitlement slug must appear in the
|
||||
-- catalog. Otherwise (NULL or empty catalog), free-text accepted.
|
||||
--
|
||||
-- See api/policies.rs::validate_entitlements_against_catalog.
|
||||
Reference in New Issue
Block a user