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>> {
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"
} 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"
};
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>> {
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 = ?",
)
.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>> {
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 = ?",
)
.bind(id)
+61
View File
@@ -3104,3 +3104,64 @@ async fn cors_preflight_returns_2xx_without_auth() {
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; }
.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>
</head>
<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)', {
type: 'number',
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(() => {
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;',
})
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.
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
@@ -2676,9 +2708,80 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
return placeholder
}
grid.appendChild(makePlaceholder())
wireTierGridDragAndDrop(grid, onMutate)
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 --------
routes.policies = async function () {
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([
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' },
'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', {
for: 'showArchivedPolicies',
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.
const productCard = el('div', { class: 'card' }, [
el('div', { class: 'card-head' }, [
el('h3', null, p.name + ' — ' + p.slug),
el('h3', null, p.name),
el('span', { class: 'sub' },
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')),
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',
}),
el('input', {
class: 'input', placeholder: 'Description (shown on buy page tooltip)',
class: 'input', placeholder: 'Description (buyer tooltip)',
value: description || '',
'data-field': 'description',
title: 'Description shown as a hover tooltip on the buy page',
}),
(() => {
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()
})
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) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })