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:
@@ -0,0 +1,30 @@
|
||||
-- Migration 0018: multi-policy scope for discount codes
|
||||
--
|
||||
-- Until now, a discount code could be scoped to exactly one policy
|
||||
-- (`applies_to_policy_id`) or "any policy on this product" / global.
|
||||
-- That left a real gap: "this code is good for Patron OR Pro but not
|
||||
-- Creator" required creating two distinct codes with different code
|
||||
-- strings, which is operationally messy.
|
||||
--
|
||||
-- New column `applies_to_policy_ids_json`: a JSON-encoded array of
|
||||
-- policy ids (TEXT uuids). Semantics:
|
||||
--
|
||||
-- NULL or '[]' → no multi-policy restriction. Fall back to the legacy
|
||||
-- `applies_to_policy_id` (single-policy scope) or, if
|
||||
-- that's also NULL, scope follows `applies_to_product_id`
|
||||
-- (or global if that's NULL too). Identical behavior
|
||||
-- to v0.2.0:19 and earlier.
|
||||
--
|
||||
-- '["id1","id2",...]' → the code applies if and only if the buyer's
|
||||
-- chosen policy is in this array. Takes
|
||||
-- precedence over `applies_to_policy_id` (the
|
||||
-- legacy singular column is ignored when this
|
||||
-- is non-empty).
|
||||
--
|
||||
-- Migration is purely additive. Existing rows have the new column NULL,
|
||||
-- so all v0.2.0:19-and-earlier discount codes keep working unchanged.
|
||||
-- New codes written via the admin API populate this column when the
|
||||
-- operator picks 2+ policies; single-policy codes continue to write to
|
||||
-- `applies_to_policy_id` for clarity.
|
||||
|
||||
ALTER TABLE discount_codes ADD COLUMN applies_to_policy_ids_json TEXT NULL;
|
||||
@@ -40,8 +40,15 @@ pub struct CreateDiscountCodeReq {
|
||||
pub product_slug: Option<String>,
|
||||
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
|
||||
/// Requires `product_slug` to be set if specified.
|
||||
/// Superseded by `policy_slugs` when both are present.
|
||||
#[serde(default)]
|
||||
pub policy_slug: Option<String>,
|
||||
/// Restrict to multiple policies (by slugs + product_slug). Omit
|
||||
/// or pass an empty list for "any policy on the product". Requires
|
||||
/// `product_slug` to be set if specified. Takes precedence over
|
||||
/// `policy_slug` when both are provided.
|
||||
#[serde(default)]
|
||||
pub policy_slugs: Option<Vec<String>>,
|
||||
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
|
||||
#[serde(default)]
|
||||
pub referrer_label: Option<String>,
|
||||
@@ -75,22 +82,51 @@ pub async fn create(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
|
||||
let pid = product_id.as_deref().ok_or_else(|| {
|
||||
AppError::BadRequest("policy_slug requires product_slug".into())
|
||||
})?;
|
||||
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"policy '{slug}' for product '{}'",
|
||||
req.product_slug.as_deref().unwrap_or("")
|
||||
))
|
||||
// Resolve policy scope. `policy_slugs` (multi) takes precedence over
|
||||
// `policy_slug` (singular legacy field). Both require `product_slug`.
|
||||
// Empty `policy_slugs` is treated as "no multi-scope" so the operator
|
||||
// can clear an existing multi-scope by passing [].
|
||||
let (policy_id, policy_ids_for_db): (Option<String>, Option<Vec<String>>) =
|
||||
if let Some(slugs) = req.policy_slugs.as_ref() {
|
||||
if slugs.is_empty() {
|
||||
(None, Some(Vec::new()))
|
||||
} else {
|
||||
let pid = product_id.as_deref().ok_or_else(|| {
|
||||
AppError::BadRequest("policy_slugs requires product_slug".into())
|
||||
})?;
|
||||
let mut ids = Vec::with_capacity(slugs.len());
|
||||
for slug in slugs {
|
||||
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"policy '{slug}' for product '{}'",
|
||||
req.product_slug.as_deref().unwrap_or("")
|
||||
))
|
||||
})?;
|
||||
ids.push(policy.id);
|
||||
}
|
||||
// For a single-policy choice, also populate the legacy
|
||||
// singular column so old readers stay coherent.
|
||||
let singular = if ids.len() == 1 { ids.first().cloned() } else { None };
|
||||
(singular, Some(ids))
|
||||
}
|
||||
} else if let Some(slug) = req.policy_slug.as_deref() {
|
||||
let pid = product_id.as_deref().ok_or_else(|| {
|
||||
AppError::BadRequest("policy_slug requires product_slug".into())
|
||||
})?;
|
||||
Some(policy.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"policy '{slug}' for product '{}'",
|
||||
req.product_slug.as_deref().unwrap_or("")
|
||||
))
|
||||
})?;
|
||||
(Some(policy.id), None)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Validate + normalize discount_currency. Accept SAT (default),
|
||||
// USD, EUR. For 'percent' codes the currency is irrelevant (basis
|
||||
@@ -121,6 +157,7 @@ pub async fn create(
|
||||
req.expires_at.as_deref(),
|
||||
product_id.as_deref(),
|
||||
policy_id.as_deref(),
|
||||
policy_ids_for_db,
|
||||
req.referrer_label.as_deref(),
|
||||
&req.description,
|
||||
req.featured,
|
||||
@@ -239,6 +276,11 @@ 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,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -432,9 +474,10 @@ pub async fn preview(
|
||||
})));
|
||||
}
|
||||
}
|
||||
if let Some(restricted_pid) = &code.applies_to_policy_id {
|
||||
let allowed = code.allowed_policy_ids();
|
||||
if !allowed.is_empty() {
|
||||
if let Some(chosen) = &chosen_policy {
|
||||
if restricted_pid != &chosen.id {
|
||||
if !allowed.iter().any(|p| *p == chosen.id) {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "wrong_tier",
|
||||
|
||||
@@ -292,13 +292,16 @@ pub async fn start(
|
||||
));
|
||||
}
|
||||
}
|
||||
// If the code is restricted to a specific policy and a tier was
|
||||
// selected, they must match. If no tier was selected, the code is
|
||||
// implicitly applied to the product's default policy at issuance
|
||||
// time, which we accept here (v0.1.0:27+).
|
||||
if let Some(restricted_pid) = &code.applies_to_policy_id {
|
||||
// If the code is restricted to one or more policies and a tier
|
||||
// was selected, the chosen tier must be in the allowed set.
|
||||
// `allowed_policy_ids()` unifies the multi-policy column (0018)
|
||||
// and the legacy singular column. If no tier was selected, the
|
||||
// code is implicitly applied to the product's default policy at
|
||||
// issuance time, which we accept here (v0.1.0:27+).
|
||||
let allowed = code.allowed_policy_ids();
|
||||
if !allowed.is_empty() {
|
||||
if let Some(chosen) = &chosen_policy {
|
||||
if restricted_pid != &chosen.id {
|
||||
if !allowed.iter().any(|p| *p == chosen.id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"discount code does not apply to the selected tier".into(),
|
||||
));
|
||||
|
||||
@@ -2071,6 +2071,15 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
|
||||
.try_get::<i64, _>("featured")
|
||||
.map(|v| v != 0)
|
||||
.unwrap_or(false);
|
||||
// Multi-policy scope JSON (0018). Parse to Vec<String>; non-array
|
||||
// / malformed JSON / pre-0018 column → empty Vec (caller falls back
|
||||
// to the singular `applies_to_policy_id`).
|
||||
let applies_to_policy_ids: Vec<String> = row
|
||||
.try_get::<Option<String>, _>("applies_to_policy_ids_json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||
.unwrap_or_default();
|
||||
DiscountCode {
|
||||
id: row.get("id"),
|
||||
code: row.get("code"),
|
||||
@@ -2081,6 +2090,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
|
||||
expires_at: row.get("expires_at"),
|
||||
applies_to_product_id: row.get("applies_to_product_id"),
|
||||
applies_to_policy_id: row.get("applies_to_policy_id"),
|
||||
applies_to_policy_ids,
|
||||
referrer_label: row.get("referrer_label"),
|
||||
description: row.get("description"),
|
||||
active: row.get::<i64, _>("active") != 0,
|
||||
@@ -2128,6 +2138,7 @@ pub async fn create_discount_code(
|
||||
expires_at,
|
||||
applies_to_product_id,
|
||||
applies_to_policy_id,
|
||||
None, // back-compat: legacy single-policy callers can't multi-scope
|
||||
referrer_label,
|
||||
description,
|
||||
false, // not featured by default — backwards-compat for callers
|
||||
@@ -2155,6 +2166,7 @@ pub async fn create_discount_code_with_currency(
|
||||
expires_at: Option<&str>,
|
||||
applies_to_product_id: Option<&str>,
|
||||
applies_to_policy_id: Option<&str>,
|
||||
applies_to_policy_ids: Option<Vec<String>>,
|
||||
referrer_label: Option<&str>,
|
||||
description: &str,
|
||||
featured: bool,
|
||||
@@ -2209,12 +2221,17 @@ pub async fn create_discount_code_with_currency(
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let stored_amount = if kind == "free_license" { 0 } else { amount };
|
||||
// Persist multi-policy scope as a JSON array. None / empty Vec →
|
||||
// NULL, so reads fall back to the singular `applies_to_policy_id`.
|
||||
let policy_ids_json: Option<String> = applies_to_policy_ids
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| serde_json::to_string(&v).unwrap_or_else(|_| "[]".to_string()));
|
||||
sqlx::query(
|
||||
"INSERT INTO discount_codes
|
||||
(id, code, kind, amount, discount_currency, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&normalized)
|
||||
@@ -2225,6 +2242,7 @@ pub async fn create_discount_code_with_currency(
|
||||
.bind(expires_at)
|
||||
.bind(applies_to_product_id)
|
||||
.bind(applies_to_policy_id)
|
||||
.bind(policy_ids_json)
|
||||
.bind(referrer_label)
|
||||
.bind(description)
|
||||
.bind(featured as i64)
|
||||
@@ -2249,7 +2267,7 @@ pub async fn get_discount_code_by_id(
|
||||
) -> AppResult<Option<DiscountCode>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes WHERE id = ?",
|
||||
)
|
||||
@@ -2266,7 +2284,7 @@ pub async fn get_discount_code_by_code(
|
||||
let normalized = code.trim().to_uppercase();
|
||||
let row = sqlx::query(
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes WHERE code = ?",
|
||||
)
|
||||
@@ -2282,12 +2300,12 @@ pub async fn list_discount_codes(
|
||||
) -> AppResult<Vec<DiscountCode>> {
|
||||
let q = if only_active {
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes WHERE active = 1 ORDER BY created_at DESC"
|
||||
} else {
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes ORDER BY created_at DESC"
|
||||
};
|
||||
@@ -2312,41 +2330,55 @@ pub async fn find_applicable_featured_discount(
|
||||
policy_id: &str,
|
||||
) -> AppResult<Option<DiscountCode>> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
// The SQL filters by featured + active + not-expired +
|
||||
// remaining-uses, scopes to either the policy, the product, or
|
||||
// global, and orders by specificity (policy match first) then
|
||||
// created_at ascending. LIMIT 1 — we only need the winner.
|
||||
let row = sqlx::query(
|
||||
// Fetch all candidate featured codes that could possibly apply
|
||||
// (correct product, or product-wide, or global). Multi-policy scope
|
||||
// narrowing happens in Rust via DiscountCode::allowed_policy_ids()
|
||||
// because the multi-policy JSON column isn't index-friendly. The
|
||||
// candidate set is small (active featured codes only), so this is
|
||||
// cheap.
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes
|
||||
WHERE featured = 1
|
||||
AND active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
AND (max_uses IS NULL OR used_count < max_uses)
|
||||
AND (
|
||||
applies_to_policy_id = ?
|
||||
OR (applies_to_policy_id IS NULL AND applies_to_product_id = ?)
|
||||
OR (applies_to_policy_id IS NULL AND applies_to_product_id IS NULL)
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN applies_to_policy_id = ? THEN 0
|
||||
WHEN applies_to_product_id = ? THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
created_at ASC
|
||||
LIMIT 1",
|
||||
AND (applies_to_product_id = ? OR applies_to_product_id IS NULL)
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(policy_id)
|
||||
.bind(product_id)
|
||||
.bind(policy_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_discount_code))
|
||||
// Score each candidate. Lower score = higher precedence:
|
||||
// 0 = code names this policy explicitly (singular or in multi list)
|
||||
// 1 = code is product-scoped (no policy restriction)
|
||||
// 2 = code is global (no product, no policy)
|
||||
// Within a tier, first-created wins (operator-set priority).
|
||||
let mut best: Option<(u8, DiscountCode)> = None;
|
||||
for row in rows {
|
||||
let code = row_to_discount_code(row);
|
||||
let allowed = code.allowed_policy_ids();
|
||||
let score: u8 = if !allowed.is_empty() {
|
||||
if allowed.iter().any(|p| *p == policy_id) {
|
||||
0
|
||||
} else {
|
||||
continue; // code restricts to other policies; skip
|
||||
}
|
||||
} else if code.applies_to_product_id.is_some() {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
match &best {
|
||||
None => best = Some((score, code)),
|
||||
Some((prev_score, _)) if score < *prev_score => best = Some((score, code)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(best.map(|(_, c)| c))
|
||||
}
|
||||
|
||||
pub async fn set_discount_code_active(
|
||||
@@ -2383,6 +2415,9 @@ 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).
|
||||
applies_to_policy_ids: Option<Vec<String>>,
|
||||
) -> AppResult<DiscountCode> {
|
||||
// Re-fetch to validate amount against the existing kind.
|
||||
let existing = get_discount_code_by_id(pool, id)
|
||||
@@ -2446,6 +2481,9 @@ pub async fn update_discount_code(
|
||||
if featured.is_some() {
|
||||
sets.push("featured = ?");
|
||||
}
|
||||
if applies_to_policy_ids.is_some() {
|
||||
sets.push("applies_to_policy_ids_json = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
@@ -2474,6 +2512,15 @@ pub async fn update_discount_code(
|
||||
if let Some(f) = featured {
|
||||
q = q.bind(f as i64);
|
||||
}
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
serde_json::to_string(&ids).ok()
|
||||
};
|
||||
q = q.bind(stored);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
q.execute(pool).await?;
|
||||
|
||||
|
||||
@@ -321,6 +321,12 @@ pub struct DiscountCode {
|
||||
pub expires_at: Option<String>,
|
||||
pub applies_to_product_id: Option<String>,
|
||||
pub applies_to_policy_id: Option<String>,
|
||||
/// Multi-policy scope (migration 0018). When non-empty, the code
|
||||
/// applies only to policies in this list — the legacy singular
|
||||
/// `applies_to_policy_id` is ignored. When empty, behavior falls
|
||||
/// back to the singular column.
|
||||
#[serde(default)]
|
||||
pub applies_to_policy_ids: Vec<String>,
|
||||
pub referrer_label: Option<String>,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
@@ -335,6 +341,27 @@ pub struct DiscountCode {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl DiscountCode {
|
||||
/// Effective allowed-policy set. Empty = no policy restriction (the
|
||||
/// code applies to any policy in the product/global scope). Non-
|
||||
/// empty = the buyer's chosen policy id must be in this list.
|
||||
///
|
||||
/// Multi-policy column (0018) takes precedence over the legacy
|
||||
/// singular column; if both are absent the result is empty.
|
||||
pub fn allowed_policy_ids(&self) -> Vec<&str> {
|
||||
if !self.applies_to_policy_ids.is_empty() {
|
||||
self.applies_to_policy_ids
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect()
|
||||
} else if let Some(pid) = self.applies_to_policy_id.as_deref() {
|
||||
vec![pid]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row per (code, invoice) pair. Status transitions:
|
||||
/// pending → redeemed (invoice settled, license issued)
|
||||
/// pending → cancelled (invoice expired or invalidated)
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user