v0.2.0:22 — Policy scope is editable on discount codes

Lifts the "scope cannot be edited" rule for policies. Product scope
remains read-only (moving a code between products has weird semantics
for historical redemptions), but the tiers a code applies to can now
be refined in-place via the Edit form's pill multi-picker.

- repo::update_discount_code: new applies_to_policy_id param
  (Option<Option<String>>) alongside the existing applies_to_policy_ids
  multi field. Both update the right columns; caller passes a
  consistent pair so singular + JSON columns don't drift.
- Admin PATCH endpoint: new optional `policy_slugs` field. Server
  resolves slugs against the code's existing product, then normalizes:
  - [] → both columns NULL (any policy on the product)
  - [one] → singular column set, JSON column cleared
  - [two+] → JSON column set, singular column cleared
  Sending no `policy_slugs` leaves scope alone (back-compat).
- Edit form: pill multi-picker replaces the read-only Applies-to label.
  Pre-selected from the code's current allowed-policy set. Product
  label stays read-only above the picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 14:19:49 -05:00
parent 6fd7dd9302
commit 3c054c65db
4 changed files with 185 additions and 55 deletions
+55 -12
View File
@@ -221,13 +221,13 @@ pub async fn get_one(
}))) })))
} }
/// Patch fields on a discount code. Only mutable fields are accepted — /// Patch fields on a discount code. Most fields are editable. The
/// `code`, `kind`, `applies_to_product`, `applies_to_policy` are /// `code` string and `kind` are not editable (identity fields), and
/// intentionally not editable to avoid silently invalidating links that /// `applies_to_product` is not editable (moving a code between products
/// have already been distributed. To change those, disable the existing /// has weird semantics for historical redemptions). Policy scope IS
/// code and create a new one. All fields are optional; `null` clears /// editable (v0.2.0:22+) so operators can refine which tiers a code
/// the field where the column is nullable (max_uses, expires_at, /// applies to without rotating the code string. All fields are optional;
/// referrer_label). /// `null` clears the field where the column is nullable.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateDiscountCodeReq { pub struct UpdateDiscountCodeReq {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -245,6 +245,13 @@ pub struct UpdateDiscountCodeReq {
/// promote, `Some(false)` to demote, omit to leave alone. /// promote, `Some(false)` to demote, omit to leave alone.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub featured: Option<bool>, pub featured: Option<bool>,
/// Policy slugs (multi). Overwrites the policy scope. Resolved
/// against the code's existing `applies_to_product_id`. Send `[]`
/// to clear the scope so the code applies to any policy on the
/// existing product. Single-element arrays are also accepted and
/// stored on the singular legacy column for clarity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_slugs: Option<Vec<String>>,
} }
/// Helper for `Option<Option<T>>` with serde — distinguishes "not present in /// Helper for `Option<Option<T>>` with serde — distinguishes "not present in
@@ -267,6 +274,45 @@ pub async fn update(
let actor_hash = require_admin(&state, &headers)?; let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers); let (ip, ua) = request_context(&headers);
// Resolve policy_slugs → policy ids using the code's EXISTING product
// (product scope is not editable here; see UpdateDiscountCodeReq).
// Three pass-throughs to update_discount_code:
// - applies_to_policy_id (singular column): set when count == 1,
// cleared when count != 1.
// - applies_to_policy_ids (JSON column): set when count >= 2,
// cleared when count <= 1.
// - both None when req.policy_slugs is absent (no change).
let (policy_id_update, policy_ids_update): (Option<Option<String>>, Option<Vec<String>>) =
match req.policy_slugs.as_ref() {
None => (None, None),
Some(slugs) => {
let existing = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
let product_id = existing.applies_to_product_id.as_deref().ok_or_else(|| {
AppError::BadRequest(
"this code is not scoped to a product, so policy scope cannot be set".into(),
)
})?;
let mut ids = Vec::with_capacity(slugs.len());
for slug in slugs {
let policy = repo::get_policy_by_slug(&state.db, product_id, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{product_id}'"
))
})?;
ids.push(policy.id);
}
match ids.len() {
0 => (Some(None), Some(Vec::new())),
1 => (Some(Some(ids[0].clone())), Some(Vec::new())),
_ => (Some(None), Some(ids)),
}
}
};
let updated = repo::update_discount_code( let updated = repo::update_discount_code(
&state.db, &state.db,
&id, &id,
@@ -276,11 +322,8 @@ pub async fn update(
req.description.as_deref(), req.description.as_deref(),
req.referrer_label.as_ref().map(|opt| opt.as_deref()), req.referrer_label.as_ref().map(|opt| opt.as_deref()),
req.featured, req.featured,
// Scope (product/policy) is intentionally not editable — see policy_id_update,
// doc-comment on UpdateDiscountCodeReq. Disable + recreate to policy_ids_update,
// re-scope a code rather than silently invalidating distributed
// links.
None,
) )
.await?; .await?;
+22 -10
View File
@@ -2397,14 +2397,15 @@ pub async fn set_discount_code_active(
} }
/// Patch mutable fields on a discount code. Mutable fields are the ones /// Patch mutable fields on a discount code. Mutable fields are the ones
/// that don't change behavior in confusing ways for codes already in /// Most fields are editable. The code string and `kind` are intentionally
/// circulation: `amount`, `max_uses`, `expires_at`, `description`, /// NOT editable — those define the identity of the code (the string buyers
/// `referrer_label`. The code string itself, kind, and product/policy /// type, and what arithmetic to apply). `applies_to_product_id` is also
/// scope are intentionally NOT editable — changing those would silently /// not editable because moving a code between products has weird semantics
/// invalidate links that are already out in the wild. Operators should /// for historical redemptions. Everything else — amount, max_uses,
/// disable + create a new code instead. Each `Option<T>` parameter is /// expires_at, description, referrer_label, featured, **and policy scope**
/// `Some(value_or_clear)` to update, `None` to leave alone; for fields /// — can be updated in place. Each `Option<T>` parameter is `Some(...)` to
/// that can be NULL'd, callers pass `Some(None)` to clear. /// update, `None` to leave alone; for fields that can be NULL'd, callers
/// pass `Some(None)` to clear.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn update_discount_code( pub async fn update_discount_code(
pool: &SqlitePool, pool: &SqlitePool,
@@ -2415,8 +2416,13 @@ pub async fn update_discount_code(
description: Option<&str>, description: Option<&str>,
referrer_label: Option<Option<&str>>, referrer_label: Option<Option<&str>>,
featured: Option<bool>, featured: Option<bool>,
// applies_to_policy_ids: None = no change, Some(vec) = overwrite // Singular policy scope: None = no change, Some(None) = clear,
// (empty vec clears the column, falling back to singular column). // Some(Some(id)) = set. Callers updating policy scope should also
// pass `applies_to_policy_ids` (or its inverse) so the two columns
// don't drift; the admin handler does this.
applies_to_policy_id: Option<Option<String>>,
// Multi-policy scope: None = no change, Some(vec) = overwrite (empty
// vec clears the column, so reads fall back to the singular column).
applies_to_policy_ids: Option<Vec<String>>, applies_to_policy_ids: Option<Vec<String>>,
) -> AppResult<DiscountCode> { ) -> AppResult<DiscountCode> {
// Re-fetch to validate amount against the existing kind. // Re-fetch to validate amount against the existing kind.
@@ -2481,6 +2487,9 @@ pub async fn update_discount_code(
if featured.is_some() { if featured.is_some() {
sets.push("featured = ?"); sets.push("featured = ?");
} }
if applies_to_policy_id.is_some() {
sets.push("applies_to_policy_id = ?");
}
if applies_to_policy_ids.is_some() { if applies_to_policy_ids.is_some() {
sets.push("applies_to_policy_ids_json = ?"); sets.push("applies_to_policy_ids_json = ?");
} }
@@ -2512,6 +2521,9 @@ pub async fn update_discount_code(
if let Some(f) = featured { if let Some(f) = featured {
q = q.bind(f as i64); q = q.bind(f as i64);
} }
if let Some(opt_pid) = applies_to_policy_id {
q = q.bind(opt_pid);
}
if let Some(ids) = applies_to_policy_ids { if let Some(ids) = applies_to_policy_ids {
// Empty list → store NULL (clear multi-scope). Non-empty → JSON. // Empty list → store NULL (clear multi-scope). Non-empty → JSON.
let stored: Option<String> = if ids.is_empty() { let stored: Option<String> = if ids.is_empty() {
+95 -32
View File
@@ -4034,36 +4034,42 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
editPanel.innerHTML = '' editPanel.innerHTML = ''
editPanel.style.display = 'block' editPanel.style.display = 'block'
// Resolve scope names (read-only display — scope can't be edited). // Product scope is read-only (changing product has weird semantics
// Product name comes from the products list we already fetched. // for historical redemptions; disable + recreate to re-product).
// Policy names require fetching the product's policies. We honor // Policy scope IS editable (0.2.0:22+) — load all policies on the
// multi-policy scope when present (0.2.0:20+); legacy single- // existing product and present a pill multi-picker, pre-selected
// policy codes display as before. // with the code's current allowed-policy set.
let scopeLabel = 'Applies to: all products on this instance' let productName = null
let productSlug = null
if (c.applies_to_product_id) { if (c.applies_to_product_id) {
const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id) const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id)
const productName = prod ? prod.name : c.applies_to_product_id productName = prod ? prod.name : c.applies_to_product_id
// Multi-policy scope wins over the legacy singular when non-empty. productSlug = prod ? prod.slug : null
const multi = Array.isArray(c.applies_to_policy_ids) && c.applies_to_policy_ids.length > 0
const policyIds = multi
? c.applies_to_policy_ids
: (c.applies_to_policy_id ? [c.applies_to_policy_id] : [])
if (policyIds.length > 0 && prod) {
let resolvedNames = policyIds.slice()
try {
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(prod.slug) +
'&include_inactive=true')
const pols = r.policies || []
resolvedNames = policyIds.map((pid) => {
const pol = pols.find((p) => p.id === pid)
return pol ? pol.name + ' (' + pol.slug + ')' : pid
})
} catch (_) {}
scopeLabel = 'Applies to: ' + productName + ' → ' + resolvedNames.join(', ')
} else {
scopeLabel = 'Applies to: ' + productName + ' (any policy)'
}
} }
const productLabel = productName
? 'Product: ' + productName + (productSlug ? ' (' + productSlug + ')' : '')
: 'Product: any (global code)'
// Pre-load policies for the product so the picker can render
// immediately. Skip when global (no product → no policies to pick).
let editPolicies = []
if (productSlug) {
try {
const r = await api('/v1/admin/policies?product_slug=' +
encodeURIComponent(productSlug) + '&include_inactive=true')
editPolicies = (r.policies || []).map((p) => ({
id: p.id, slug: p.slug, name: p.name,
}))
} catch (_) {}
}
// Multi-policy scope wins over the legacy singular when non-empty.
const editInitialIds = (() => {
if (Array.isArray(c.applies_to_policy_ids) && c.applies_to_policy_ids.length > 0) {
return new Set(c.applies_to_policy_ids)
}
if (c.applies_to_policy_id) return new Set([c.applies_to_policy_id])
return new Set()
})()
const amtField = formInput('e_amount', 'Amount', { const amtField = formInput('e_amount', 'Amount', {
type: 'number', type: 'number',
@@ -4158,6 +4164,50 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
'When on: display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'), 'When on: display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'),
]) ])
})() })()
// Policy scope multi-pill picker. Hidden when the code is global
// (no product) since there are no policies to choose from.
const editPolicyHost = el('div', {
'data-edit-policy-pills': '1',
style: 'display:flex; flex-wrap:wrap; gap:6px; min-height:32px; ' +
'padding:6px 8px; border:1px solid var(--border-1); border-radius:8px; ' +
'background:var(--cream-50);',
})
editPolicyHost._selected = new Set(editInitialIds)
function renderEditPolicyPills() {
editPolicyHost.innerHTML = ''
if (!editPolicies.length) {
editPolicyHost.appendChild(el('span', {
class: 'muted', style: 'font-size:12px; align-self:center',
}, productSlug ? 'No policies on this product yet.' : 'Global code — no policies to pick.'))
return
}
editPolicies.forEach((p) => {
const on = editPolicyHost._selected.has(p.id)
const pill = el('button', {
type: 'button',
'data-policy-id': p.id,
style: 'font-size:12px; padding:4px 12px; border-radius:999px; cursor:pointer; ' +
'border:1.5px solid ' + (on ? 'var(--gold-700)' : 'var(--border-1)') + '; ' +
'background:' + (on ? 'var(--navy-950)' : 'var(--cream-100)') + '; ' +
'color:' + (on ? 'var(--gold-500)' : 'var(--ink-700)') + '; ' +
'font-weight:' + (on ? '600' : '500') + ';',
}, p.name)
pill.addEventListener('click', () => {
if (editPolicyHost._selected.has(p.id)) editPolicyHost._selected.delete(p.id)
else editPolicyHost._selected.add(p.id)
renderEditPolicyPills()
})
editPolicyHost.appendChild(pill)
})
}
renderEditPolicyPills()
const policyScopeFieldEdit = productSlug ? el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Restrict to policies (optional)'),
editPolicyHost,
el('p', { class: 'muted', style: 'margin:4px 0 0; font-size:11.5px; line-height:1.4' },
'Click a tier to toggle. Pick 0 for "any policy on this product"; 2+ to scope to a subset.'),
]) : null
const saveBtn = el('button', { class: 'btn primary', onclick: async function () { const saveBtn = el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
editPanel.appendChild(status) editPanel.appendChild(status)
@@ -4187,6 +4237,18 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
body.referrer_label = refRaw === '' ? null : refRaw body.referrer_label = refRaw === '' ? null : refRaw
body.description = editPanel.querySelector('[name=e_description]').value || '' body.description = editPanel.querySelector('[name=e_description]').value || ''
body.featured = editPanel.querySelector('[name=e_featured]').checked body.featured = editPanel.querySelector('[name=e_featured]').checked
// Policy scope (editable since v0.2.0:22). Only sent when the
// code is scoped to a product — global codes have no policy
// picker. Send the selected ids' slugs as an array (server
// accepts 0/1/N and normalizes both singular + multi columns).
if (productSlug) {
const pickedIds = Array.from(editPolicyHost._selected)
const pickedSlugs = pickedIds.map((pid) => {
const found = editPolicies.find((p) => p.id === pid)
return found ? found.slug : null
}).filter(Boolean)
body.policy_slugs = pickedSlugs
}
await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body }) await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body })
status.replaceWith(ok('Saved. Reloading…')) status.replaceWith(ok('Saved. Reloading…'))
setTimeout(routes.codes, 600) setTimeout(routes.codes, 600)
@@ -4205,20 +4267,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('strong', null, 'Editing code '), el('strong', null, 'Editing code '),
el('code', { style: 'font-size:14px' }, c.code), el('code', { style: 'font-size:14px' }, c.code),
]), ]),
// Read-only scope display — the data the operator most often // Product scope is read-only (changing product has weird semantics
// needs to see when reviewing a code. Distinct from the // for historical redemptions). Policy scope is editable via the
// editable fields below. // pill picker below.
el('div', { el('div', {
style: style:
'padding:8px 12px; margin-bottom:12px; ' + 'padding:8px 12px; margin-bottom:12px; ' +
'background:var(--cream-100); border-radius:6px; ' + 'background:var(--cream-100); border-radius:6px; ' +
'font-size:12.5px; color:var(--ink-700);', 'font-size:12.5px; color:var(--ink-700);',
}, scopeLabel), }, productLabel),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' }, el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Editable: amount, max uses, expiry, referrer label, description, featured flag. The code string, kind, and product/policy scope cannot be changed — disable + create a new code instead.'), 'The code string, kind, and product cannot be changed — disable + create a new code instead. Everything else (amount, max uses, expiry, label, description, featured flag, and which policies the code applies to) is editable.'),
el('div', { class: 'row-2' }, [amtField, muField]), el('div', { class: 'row-2' }, [amtField, muField]),
el('div', { class: 'row-2' }, [expField, refField]), el('div', { class: 'row-2' }, [expField, refField]),
descField, descField,
policyScopeFieldEdit,
featuredField, featuredField,
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]), el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
])) ]))
+13 -1
View File
@@ -58,6 +58,18 @@ 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:22 — **Policy scope is now editable on existing discount codes.** Previously, the Edit form showed scope as a read-only "Applies to:" label and forced operators to disable + recreate any code whose tier scope needed adjusting. That rule existed to "avoid silently invalidating distributed links" — but the same argument applies to `amount`, `max_uses`, and `expires_at`, all of which are already editable. Inconsistent. So: policy scope joins them.',
'',
'**Edit form: pill multi-picker for policy scope.** The Edit modal now renders the same gold-on-navy pill picker as Create, pre-selected with the code\'s current allowed-policy set. Toggle pills to refine: 0 picked → "any policy on this product"; 1 picked → singular scope (writes the legacy column); 2+ picked → multi-policy scope (writes the JSON column).',
'',
'**Product scope: still read-only.** Moving a discount code from one product to another has weirder semantics for historical redemptions (a redeemed code now points at a product it never originally applied to), so the product field stays locked. To re-product a code: disable + recreate.',
'',
'**API.** `PATCH /v1/admin/discount-codes/<id>` now accepts an optional `policy_slugs: string[]` field. Server resolves slugs against the code\'s existing product, then normalizes: empty → both scope columns NULL, single → singular column populated + JSON column cleared, multi → JSON column populated + singular column cleared. Sending no `policy_slugs` field at all leaves scope alone (back-compat).',
'',
'**Test count: 87** (unchanged — same data model as v0.2.0:20, just exposed on the update path).',
'',
'**Upgrade path.** v0.2.0:21 → v0.2.0:22 is a drop-in. No schema, no SDK breaking change.',
'',
'0.2.0:21 — **Wider buy page so 3-tier grids breathe.** The public /buy/<slug> page was capped at 560px, which packed three tier cards into a too-narrow column on desktop browsers. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. The form, price card, and intro text below the tier picker remain centered at the 560px reading-width so the buy form doesn\'t look stretched. Mobile (≤480px) breakpoint unchanged. Topbar inner widened to match. UI-only; no API or schema change.', '0.2.0:21 — **Wider buy page so 3-tier grids breathe.** The public /buy/<slug> page was capped at 560px, which packed three tier cards into a too-narrow column on desktop browsers. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. The form, price card, and intro text below the tier picker remain centered at the 560px reading-width so the buy form doesn\'t look stretched. Mobile (≤480px) breakpoint unchanged. Topbar inner widened to match. UI-only; no API or schema change.',
'', '',
'0.2.0:20 — **Discount codes can apply to multiple policies, not just one.** Operator picks a subset (e.g. "Patron AND Pro but not Creator") on a single code instead of cloning the code under different names.', '0.2.0:20 — **Discount codes can apply to multiple policies, not just one.** Operator picks a subset (e.g. "Patron AND Pro but not Creator") on a single code instead of cloning the code under different names.',
@@ -364,7 +376,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:21', version: '0.2.0:22',
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