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:
@@ -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?;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user