v0.2.0:20 — Multi-policy scope for discount codes

A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.

- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
  of policy ids). Legacy `applies_to_policy_id` stays as the singular
  fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
  into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
  candidates in Rust (small candidate set; index-friendly SQL would
  require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
  (array) alongside the existing `policy_slug` (singular). Multi wins
  when both are present. PATCH does not allow scope edits — same rule
  as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
  form. Edit modal's scope label renders the comma-separated list.

UI + schema both back-compat: existing codes keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 14:01:51 -05:00
parent eb360a325e
commit 094cf75e52
7 changed files with 306 additions and 77 deletions
+86 -23
View File
@@ -3757,32 +3757,78 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const productScopeField = formSelect('product_slug', 'Restrict to product (optional)',
productOptions, { value: '' })
// Policy picker: populated on-demand when a product is selected.
// Disabled when "Any product" is selected (a policy-scoped code
// requires a product scope per the server contract).
const policyScopeField = formSelect('policy_slug', 'Restrict to policy (optional)',
[{ value: '', label: '— Any policy —' }], { value: '' })
const policySel = policyScopeField.querySelector('select')
policySel.disabled = true
// Policy picker: multi-select pill picker populated on-demand when
// a product is selected. Operator picks zero or more policies:
// - 0 picked = code applies to any policy on the chosen product
// - 1 picked = code is single-policy scoped (mirrors v0.2.0:19)
// - 2+ picked = code applies to any of the picked policies
// Hidden when "Any product" is selected — a policy-scoped code
// requires a product scope per the server contract.
const policyMultiHost = el('div', {
'data-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);',
}, [
el('span', { class: 'muted', style: 'font-size:12px; align-self:center' },
'Pick a product to choose policies.'),
])
const policyScopeField = el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Restrict to policies (optional)'),
policyMultiHost,
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 the code to a specific subset (e.g. Patron AND Pro but not Creator).'),
])
// Stash the current set as a small reactive structure on the host.
// Read by the submit handler via `policyMultiHost._selected`.
policyMultiHost._selected = new Set()
policyMultiHost._available = []
function renderPolicyPills() {
policyMultiHost.innerHTML = ''
if (!policyMultiHost._available.length) {
policyMultiHost.appendChild(el('span', {
class: 'muted', style: 'font-size:12px; align-self:center',
}, 'Pick a product to choose policies.'))
return
}
policyMultiHost._available.forEach((p) => {
const on = policyMultiHost._selected.has(p.slug)
const pill = el('button', {
type: 'button',
'data-policy-slug': p.slug,
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 (policyMultiHost._selected.has(p.slug)) policyMultiHost._selected.delete(p.slug)
else policyMultiHost._selected.add(p.slug)
renderPolicyPills()
})
policyMultiHost.appendChild(pill)
})
}
productScopeField.querySelector('select').addEventListener('change', async (e) => {
const slug = e.target.value
policySel.innerHTML = ''
policySel.appendChild(el('option', { value: '' }, '— Any policy —'))
policyMultiHost._selected = new Set()
policyMultiHost._available = []
if (!slug) {
policySel.disabled = true
renderPolicyPills()
return
}
policySel.disabled = false
try {
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
'&include_inactive=true')
const pols = r.policies || []
pols.forEach((p) => policySel.appendChild(el('option', { value: p.slug }, p.name)))
policyMultiHost._available = (r.policies || []).map((p) => ({ slug: p.slug, name: p.name }))
} catch (_) {
// Silent: operator can still create a product-scoped code
// without a policy if the policy fetch fails.
// without picking policies if the policy fetch fails.
}
renderPolicyPills()
})
const create = el('details', { class: 'disclosure' }, [
@@ -3924,8 +3970,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
const ps = create.querySelector('[name=product_slug]').value.trim()
if (ps) body.product_slug = ps
const pol = create.querySelector('[name=policy_slug]').value.trim()
if (pol) body.policy_slug = pol
// Multi-policy scope: read from the pill host's _selected set.
// 0 picked = "any policy on the product" (omit field).
// 1 picked = send as singular policy_slug for legacy clarity.
// 2+ picked = send as policy_slugs array (server prefers this).
const pickedPolicySlugs = Array.from(policyMultiHost._selected)
if (pickedPolicySlugs.length === 1) {
body.policy_slug = pickedPolicySlugs[0]
} else if (pickedPolicySlugs.length > 1) {
body.policy_slugs = pickedPolicySlugs
}
const rl = create.querySelector('[name=referrer_label]').value.trim()
if (rl) body.referrer_label = rl
const featured = create.querySelector('[name=featured]').checked
@@ -3982,21 +4036,30 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// Resolve scope names (read-only display — scope can't be edited).
// Product name comes from the products list we already fetched.
// Policy name requires fetching the product's policies, done
// lazily here when an actual policy_id is bound.
// 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'
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
if (c.applies_to_policy_id && prod) {
let policyName = c.applies_to_policy_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 pol = (r.policies || []).find((p) => p.id === c.applies_to_policy_id)
if (pol) policyName = pol.name + ' (' + pol.slug + ')'
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 + ' → ' + policyName
scopeLabel = 'Applies to: ' + productName + ' → ' + resolvedNames.join(', ')
} else {
scopeLabel = 'Applies to: ' + productName + ' (any policy)'
}