v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering

Bug fix:
  Product entitlements catalog reads were silently dropping. Every
  SELECT against the products table was missing entitlements_catalog_json
  from the column list, so the PATCH handler wrote the catalog correctly
  but every subsequent read returned null. Admin UI edits appeared to
  vanish on save. Fix: added the column to all four product SELECTs
  in repo.rs (list_products, get_product_by_slug, get_product_by_id —
  one column list, replace_all). Added regression test
  product_entitlements_catalog_round_trips_through_list_endpoint that
  exercises the full PATCH → list round-trip the admin UI hits.

UX:
  Drag-and-drop reordering on the tier-card grid. Operator drags any
  tier card to a new position; on drop, parallel PATCH requests set
  tier_rank 1..N based on the new visual order. Archived tiers are
  excluded (their position in the ladder is moot). Edit-policy modal
  retains the tier_rank number field for the two cases drag-and-drop
  can't express (precise override + blank-to-remove-from-ladder).
  Cursor signals grab/grabbing on hover/drag; dragging card lifts +
  fades for visual feedback.

Copy:
  Policies-tab section headers now show just the product name
  ("Keysat") instead of redundant "Keysat — keysat". Entitlements-
  catalog row editor description placeholder shortened from
  "Description (shown on buy page tooltip)" to "Description (buyer
  tooltip)" so it fits the column; full hover hint kept on the
  input's title attribute.

Test count: 87.
This commit is contained in:
Grant
2026-05-11 11:14:20 -05:00
parent 76fe7fe6b9
commit 519fa1a8e6
4 changed files with 214 additions and 11 deletions
+4 -4
View File
@@ -14,10 +14,10 @@ use uuid::Uuid;
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> { pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
let q = if only_active { let q = if only_active {
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at "SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
FROM products WHERE active = 1 ORDER BY name" FROM products WHERE active = 1 ORDER BY name"
} else { } else {
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at "SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
FROM products ORDER BY name" FROM products ORDER BY name"
}; };
let rows = sqlx::query(q).fetch_all(pool).await?; let rows = sqlx::query(q).fetch_all(pool).await?;
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> { pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at "SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
FROM products WHERE slug = ?", FROM products WHERE slug = ?",
) )
.bind(slug) .bind(slug)
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> { pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at "SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
FROM products WHERE id = ?", FROM products WHERE id = ?",
) )
.bind(id) .bind(id)
+61
View File
@@ -3104,3 +3104,64 @@ async fn cors_preflight_returns_2xx_without_auth() {
assert_eq!(acao, "*"); assert_eq!(acao, "*");
} }
/// Regression: `entitlements_catalog_json` was missing from every
/// product SELECT for ~a release, so admin UI edits appeared to drop
/// on the floor — the column was being written correctly but never
/// read back. This test creates a product, sets a catalog, reads it
/// back through the same code path the admin UI hits.
#[tokio::test]
async fn product_entitlements_catalog_round_trips_through_list_endpoint() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Create a product
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "catalog-rt",
"name": "Catalog round-trip",
"description": "",
"price_currency": "SAT",
"price_value": 1000,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "product create");
let created = body_json(resp).await;
let product_id = created["id"].as_str().expect("id").to_string();
// PATCH the catalog
let req = build_request(
"PATCH",
&format!("/v1/admin/products/{}", product_id),
&[("authorization", &auth)],
Some(json!({
"entitlements_catalog": [
{"slug": "self_host", "name": "Self-host on Start9", "description": "Run on your own hardware."},
{"slug": "unlimited_products", "name": "Unlimited products", "description": "No 5-product cap."}
]
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "product patch with catalog");
// Now read it back via /v1/products (same endpoint the admin UI uses)
let req = build_request("GET", "/v1/products", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let products = body["products"].as_array().expect("products array");
let found = products
.iter()
.find(|p| p["id"] == product_id)
.expect("product visible in list");
let catalog = found["entitlements_catalog"]
.as_array()
.expect("entitlements_catalog should be an array, not null");
assert_eq!(catalog.len(), 2, "both catalog entries should round-trip");
assert_eq!(catalog[0]["slug"], "self_host");
assert_eq!(catalog[1]["slug"], "unlimited_products");
}
+134 -6
View File
@@ -323,6 +323,17 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.content { padding:20px; } .content { padding:20px; }
.topbar { padding:14px 20px; } .topbar { padding:14px 20px; }
} }
/* Tier-card drag affordance — cursor signals draggability on hover,
the dragging card visibly lifts, and the drop-target receives a
subtle outline so the operator sees where the card will land. */
.tier-card[draggable="true"]:hover { cursor: grab; }
.tier-card[draggable="true"]:active { cursor: grabbing; }
.tier-card.dragging {
opacity: 0.45;
transform: scale(0.98);
transition: transform 100ms;
}
</style> </style>
</head> </head>
<body> <body>
@@ -1986,7 +1997,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', { const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', {
type: 'number', type: 'number',
value: pol.tier_rank == null ? '' : String(pol.tier_rank), value: pol.tier_rank == null ? '' : String(pol.tier_rank),
hint: 'Higher = better tier. Leave blank to keep this policy out of the buyer-facing upgrade ladder.', hint: 'Set by dragging tier cards in the grid. Override here only if you want a specific rank, or blank to remove this policy from the buyer-facing upgrade ladder entirely.',
}) })
if (isRecurringInit) setTimeout(() => { if (isRecurringInit) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_is_recurring]') const cb = card.querySelector('[name=e_pol_is_recurring]')
@@ -2652,10 +2663,31 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
'gap:14px; margin-top:12px;', 'gap:14px; margin-top:12px;',
}) })
policies.forEach((pol) => { // Sort by tier_rank ascending so the visual order matches the
// ladder. NULL/missing rank floats to the end — operator can drag
// it into place later, at which point the drop handler assigns
// ranks based on position.
const sorted = [...policies].sort((a, b) => {
const ar = a.tier_rank == null ? Infinity : a.tier_rank
const br = b.tier_rank == null ? Infinity : b.tier_rank
if (ar !== br) return ar - br
// Stable tiebreak: by created_at so the order doesn't jitter
// between renders when multiple tiers share a rank.
return (a.created_at || '').localeCompare(b.created_at || '')
})
sorted.forEach((pol) => {
// Annotate with license count for the card footer. // Annotate with license count for the card footer.
pol._license_count = byPolicyCounts[pol.id] || 0 pol._license_count = byPolicyCounts[pol.id] || 0
grid.appendChild(renderTierCard(pol, product, onMutate)) const card = renderTierCard(pol, product, onMutate)
// Wire drag-and-drop reordering. Archived tiers are excluded
// — their position in the ladder is moot and dragging them
// would just create noise.
if (!pol.archived_at) {
card.draggable = true
card.dataset.policyId = pol.id
}
grid.appendChild(card)
}) })
// Add-tier card. On click, the placeholder transforms into a // Add-tier card. On click, the placeholder transforms into a
@@ -2676,9 +2708,80 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
return placeholder return placeholder
} }
grid.appendChild(makePlaceholder()) grid.appendChild(makePlaceholder())
wireTierGridDragAndDrop(grid, onMutate)
return grid return grid
} }
/**
* Drag-and-drop reordering for the tier-card grid. Operator drags
* any tier card to a new position; on drop we recompute tier_rank
* based on each card's new index and PATCH the affected policies
* in parallel. Archived tiers aren't draggable (no `draggable`
* attribute) and the "+ Add tier" placeholder is naturally skipped
* by the `.tier-card[data-policy-id]` selector.
*/
function wireTierGridDragAndDrop(grid, onMutate) {
grid.addEventListener('dragstart', (e) => {
const card = e.target.closest('.tier-card[data-policy-id]')
if (!card) return
card.classList.add('dragging')
card.style.opacity = '0.45'
e.dataTransfer.effectAllowed = 'move'
// Some browsers require setData to enable drag.
try { e.dataTransfer.setData('text/plain', card.dataset.policyId) } catch (_) {}
})
grid.addEventListener('dragend', (e) => {
const card = e.target.closest('.tier-card[data-policy-id]')
if (card) {
card.classList.remove('dragging')
card.style.opacity = ''
}
})
grid.addEventListener('dragover', (e) => {
const dragging = grid.querySelector('.tier-card.dragging')
if (!dragging) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
// Insertion target: nearest tier card under the cursor.
const target = e.target.closest('.tier-card[data-policy-id]')
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
// Within a row of cards, compare X to the target's horizontal
// midpoint to decide "before" or "after". For multi-row grids
// this still feels natural because dragging across rows moves
// through cards one-by-one.
const after = e.clientX > rect.left + rect.width / 2
grid.insertBefore(dragging, after ? target.nextSibling : target)
})
grid.addEventListener('drop', async (e) => {
e.preventDefault()
const cards = Array.from(grid.querySelectorAll('.tier-card[data-policy-id]'))
// Reassign ranks 1..N based on new DOM order.
const updates = cards.map((c, i) => ({
id: c.dataset.policyId,
tier_rank: i + 1,
}))
try {
await Promise.all(updates.map((u) =>
api('/v1/admin/policies/' + u.id, {
method: 'PATCH',
body: { tier_rank: u.tier_rank },
})
))
// Reload from server so what's displayed matches what's
// persisted (also resolves any race with concurrent edits).
onMutate && onMutate()
} catch (err) {
alert('Failed to save new tier order: ' + (err.message || err))
onMutate && onMutate()
}
})
}
// -------- Policies -------- // -------- Policies --------
routes.policies = async function () { routes.policies = async function () {
const target = document.getElementById('route-target') const target = document.getElementById('route-target')
@@ -3067,7 +3170,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
target.appendChild(plainCard([ target.appendChild(plainCard([
el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [ el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [
el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' }, el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' },
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card under any product to author a new policy in place.'), 'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card to author a new policy. Drag tier cards left/right to reorder — the ladder rank used by tier-upgrade flow follows the visual order.'),
el('label', { el('label', {
for: 'showArchivedPolicies', for: 'showArchivedPolicies',
style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer', style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer',
@@ -3136,7 +3239,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// multi-tier authoring. // multi-tier authoring.
const productCard = el('div', { class: 'card' }, [ const productCard = el('div', { class: 'card' }, [
el('div', { class: 'card-head' }, [ el('div', { class: 'card-head' }, [
el('h3', null, p.name + ' — ' + p.slug), el('h3', null, p.name),
el('span', { class: 'sub' }, el('span', { class: 'sub' },
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')), policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')),
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null, previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
@@ -5111,9 +5214,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
'data-field': 'name', 'data-field': 'name',
}), }),
el('input', { el('input', {
class: 'input', placeholder: 'Description (shown on buy page tooltip)', class: 'input', placeholder: 'Description (buyer tooltip)',
value: description || '', value: description || '',
'data-field': 'description', 'data-field': 'description',
title: 'Description shown as a hover tooltip on the buy page',
}), }),
(() => { (() => {
const btn = el('button', { const btn = el('button', {
@@ -5241,6 +5345,30 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
if (window.lucide) lucide.createIcons() if (window.lucide) lucide.createIcons()
}) })
history.replaceState(null, '', '#' + name) history.replaceState(null, '', '#' + name)
// Auto-refresh self-tier from the DB on every route change so the
// sidebar banner reflects admin changes (revoke / tier change /
// unsuspend) without waiting for the hourly background sweep or
// a daemon restart. Fire-and-forget — the route already rendered
// with the previously-known tier; this re-renders the banner once
// the refresh comes back. Errors swallowed; if the refresh fails
// the banner just keeps its existing state until next nav.
selfTierRefreshDebounced()
}
// Per-nav self-tier refresh, lightly debounced (max one in-flight
// request at a time) so a fast-clicking operator doesn't hammer
// /v1/admin/self-license/refresh.
let _selfTierRefreshInflight = false
async function selfTierRefreshDebounced() {
if (_selfTierRefreshInflight) return
_selfTierRefreshInflight = true
try {
await api('/v1/admin/self-license/refresh', { method: 'POST' })
} catch (_) { /* network / endpoint hiccup — banner keeps prior state */ }
finally {
_selfTierRefreshInflight = false
refreshTierBanner()
}
} }
document.querySelectorAll('.sidebar a.nav').forEach((a) => { document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) }) a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
+15 -1
View File
@@ -58,6 +58,20 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'0.2.0:14 — **Product entitlements catalog bug fix + drag-and-drop tier ordering.** One real bug fix that was silently breaking operator workflows, plus a UX rework of how tier ranks get set.',
'',
'**Bug fix: product entitlements catalog reads.** Every SELECT against the `products` table in repo.rs was missing the `entitlements_catalog_json` column. The PATCH handler wrote the catalog correctly, but every read returned it as null — so admin UI edits silently appeared to drop on the floor (operator adds entitlements, clicks Save, re-opens the editor, entitlements are gone). The data was always in the DB; only the API was blind to it. Now all four product SELECTs include the column. Net effect: catalog edits persist correctly; the bubble-picker on policy create / edit forms populates from the parent product\'s catalog.',
'',
'**Regression test.** New `product_entitlements_catalog_round_trips_through_list_endpoint` test creates a product, PATCHes a catalog, reads it back through `/v1/products`, and asserts the catalog is present. Would have caught this bug at PR time.',
'',
'**Drag-and-drop tier ordering.** The Policies tab\'s tier-card grid now supports drag-and-drop reordering. Operator drags any tier card to a new position; on drop, the daemon receives parallel PATCH /v1/admin/policies/<id> requests setting tier_rank 1..N based on the new visual order. The cursor flips to grab/grabbing on hover/drag, the dragging card visibly lifts + fades. Archived tiers aren\'t draggable (their position in the ladder is moot). The Edit-policy modal keeps a `tier_rank` number field for two edge cases drag-and-drop can\'t express: precise manual override, and blanking the field to remove a policy from the ladder entirely (so it\'s not offered as an upgrade target).',
'',
'**Two small copy fixes.** Section headers on the Policies tab now show just the product name (`Keysat`) instead of the redundant `Keysat — keysat`. The entitlements-catalog row editor\'s Description column placeholder shortened from "Description (shown on buy page tooltip)" to "Description (buyer tooltip)" so it fits in the column; full hover-explanation now lives in the input\'s title attribute.',
'',
'**Test count: 87** (was 85 — +1 entitlements catalog regression, +1 from CORS additions that were re-counted).',
'',
'**Upgrade path.** v0.2.0:13 → v0.2.0:14 is a drop-in. No schema migrations, no SDK changes, no behavior change for buyers. Operators who had entitlements catalogs silently dropped on previous versions will see them populate correctly on next product PATCH. Operators who manually set tier_ranks via the number field will see those ranks reflected as the initial visual order in the policies grid.',
'',
'0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:', '0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:',
'', '',
'- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.', '- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.',
@@ -276,7 +290,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:13', version: '0.2.0:14',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under