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:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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')) })
|
||||
|
||||
Reference in New Issue
Block a user