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