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 —
/// `code`, `kind`, `applies_to_product`, `applies_to_policy` are
/// intentionally not editable to avoid silently invalidating links that
/// have already been distributed. To change those, disable the existing
/// code and create a new one. All fields are optional; `null` clears
/// the field where the column is nullable (max_uses, expires_at,
/// referrer_label).
/// Patch fields on a discount code. Most fields are editable. The
/// `code` string and `kind` are not editable (identity fields), and
/// `applies_to_product` is not editable (moving a code between products
/// has weird semantics for historical redemptions). Policy scope IS
/// editable (v0.2.0:22+) so operators can refine which tiers a code
/// applies to without rotating the code string. All fields are optional;
/// `null` clears the field where the column is nullable.
#[derive(Debug, Deserialize)]
pub struct UpdateDiscountCodeReq {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -245,6 +245,13 @@ pub struct UpdateDiscountCodeReq {
/// promote, `Some(false)` to demote, omit to leave alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
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
@@ -267,6 +274,45 @@ pub async fn update(
let actor_hash = require_admin(&state, &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(
&state.db,
&id,
@@ -276,11 +322,8 @@ pub async fn update(
req.description.as_deref(),
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
req.featured,
// Scope (product/policy) is intentionally not editable — see
// doc-comment on UpdateDiscountCodeReq. Disable + recreate to
// re-scope a code rather than silently invalidating distributed
// links.
None,
policy_id_update,
policy_ids_update,
)
.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
/// that don't change behavior in confusing ways for codes already in
/// circulation: `amount`, `max_uses`, `expires_at`, `description`,
/// `referrer_label`. The code string itself, kind, and product/policy
/// scope are intentionally NOT editable — changing those would silently
/// invalidate links that are already out in the wild. Operators should
/// disable + create a new code instead. Each `Option<T>` parameter is
/// `Some(value_or_clear)` to update, `None` to leave alone; for fields
/// that can be NULL'd, callers pass `Some(None)` to clear.
/// Most fields are editable. The code string and `kind` are intentionally
/// NOT editable — those define the identity of the code (the string buyers
/// type, and what arithmetic to apply). `applies_to_product_id` is also
/// not editable because moving a code between products has weird semantics
/// for historical redemptions. Everything else — amount, max_uses,
/// expires_at, description, referrer_label, featured, **and policy scope**
/// — can be updated in place. Each `Option<T>` parameter is `Some(...)` to
/// update, `None` to leave alone; for fields that can be NULL'd, callers
/// pass `Some(None)` to clear.
#[allow(clippy::too_many_arguments)]
pub async fn update_discount_code(
pool: &SqlitePool,
@@ -2415,8 +2416,13 @@ pub async fn update_discount_code(
description: Option<&str>,
referrer_label: Option<Option<&str>>,
featured: Option<bool>,
// applies_to_policy_ids: None = no change, Some(vec) = overwrite
// (empty vec clears the column, falling back to singular column).
// Singular policy scope: None = no change, Some(None) = clear,
// 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>>,
) -> AppResult<DiscountCode> {
// Re-fetch to validate amount against the existing kind.
@@ -2481,6 +2487,9 @@ pub async fn update_discount_code(
if featured.is_some() {
sets.push("featured = ?");
}
if applies_to_policy_id.is_some() {
sets.push("applies_to_policy_id = ?");
}
if applies_to_policy_ids.is_some() {
sets.push("applies_to_policy_ids_json = ?");
}
@@ -2512,6 +2521,9 @@ pub async fn update_discount_code(
if let Some(f) = featured {
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 {
// Empty list → store NULL (clear multi-scope). Non-empty → JSON.
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.style.display = 'block'
// Resolve scope names (read-only display — scope can't be edited).
// Product name comes from the products list we already fetched.
// Policy names require fetching the product's policies. We honor
// multi-policy scope when present (0.2.0:20+); legacy single-
// policy codes display as before.
let scopeLabel = 'Applies to: all products on this instance'
// Product scope is read-only (changing product has weird semantics
// for historical redemptions; disable + recreate to re-product).
// Policy scope IS editable (0.2.0:22+) — load all policies on the
// existing product and present a pill multi-picker, pre-selected
// with the code's current allowed-policy set.
let productName = null
let productSlug = null
if (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
// Multi-policy scope wins over the legacy singular when non-empty.
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)'
}
productName = prod ? prod.name : c.applies_to_product_id
productSlug = prod ? prod.slug : null
}
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', {
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.'),
])
})()
// 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 status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
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.description = editPanel.querySelector('[name=e_description]').value || ''
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 })
status.replaceWith(ok('Saved. Reloading…'))
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('code', { style: 'font-size:14px' }, c.code),
]),
// Read-only scope display — the data the operator most often
// needs to see when reviewing a code. Distinct from the
// editable fields below.
// Product scope is read-only (changing product has weird semantics
// for historical redemptions). Policy scope is editable via the
// pill picker below.
el('div', {
style:
'padding:8px 12px; margin-bottom:12px; ' +
'background:var(--cream-100); border-radius:6px; ' +
'font-size:12.5px; color:var(--ink-700);',
}, scopeLabel),
}, productLabel),
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' }, [expField, refField]),
descField,
policyScopeFieldEdit,
featuredField,
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
]))