Wire product→merchant-profile write path
Multi-profile resolution shipped in :52 but nothing wrote products.merchant_profile_id, so it was non-functional end to end. Add merchant_profile_id to the Product model + all four product SELECTs, a set_product_merchant_profile writer (validates the target profile exists, returning 404 instead of a raw FK-violation 500), and thread an optional field through CreateProductReq (post-write) and UpdateProductReq (double-Option; Some(None) clears to default). The admin SPA product form shows a profile picker only when >1 profile exists. Mirrors the entitlements-catalog post-write pattern. Tests: repo round-trip (attach/resolve/clear/bad-id) + HTTP handler arms. api suite 54→56, full suite green.
This commit is contained in:
@@ -107,6 +107,11 @@ pub struct CreateProductReq {
|
||||
/// policies can carry any entitlement string.
|
||||
#[serde(default)]
|
||||
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
|
||||
/// Merchant profile to attach the product to (migration 0020).
|
||||
/// Omit / null to resolve to the default profile. Only meaningful
|
||||
/// when the operator runs more than one profile.
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Currencies the admin endpoints accept. Whitelist enforced here so
|
||||
@@ -212,6 +217,17 @@ pub async fn create_product(
|
||||
} else {
|
||||
product
|
||||
};
|
||||
// Attach to a merchant profile if the operator picked one (same
|
||||
// post-write pattern as the entitlements catalog). Omitted = NULL =
|
||||
// resolves to the default profile. A bad profile id 404s here AFTER
|
||||
// the row exists, leaving it with a NULL profile — benign (resolves
|
||||
// to default; reattach or delete). The admin UI only offers existing
|
||||
// profiles, so this is an API-direct edge only.
|
||||
let product = if let Some(profile_id) = req.merchant_profile_id.as_deref() {
|
||||
repo::set_product_merchant_profile(&state.db, &product.id, Some(profile_id)).await?
|
||||
} else {
|
||||
product
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
@@ -437,6 +453,20 @@ pub struct UpdateProductReq {
|
||||
/// string until the catalog is set again.
|
||||
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
|
||||
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
|
||||
/// Reassign the product's merchant profile (migration 0020).
|
||||
/// `Some(Some(id))` attaches, `Some(None)` clears it back to
|
||||
/// default-resolution, omit / absent leaves it unchanged.
|
||||
#[serde(default, deserialize_with = "deser_double_option_profile", skip_serializing_if = "Option::is_none")]
|
||||
pub merchant_profile_id: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// Serde adapter for the nullable merchant-profile patch — same
|
||||
/// "omitted vs null vs value" three-way distinction as the catalog.
|
||||
fn deser_double_option_profile<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(de).map(Some)
|
||||
}
|
||||
|
||||
/// Serde adapter — distinguishes "field omitted" (None) from
|
||||
@@ -533,6 +563,16 @@ pub async fn update_product(
|
||||
}
|
||||
None => updated,
|
||||
};
|
||||
// Merchant-profile reassignment, same three-way patch as the
|
||||
// catalog: Some(Some) attaches, Some(None) clears to default, None
|
||||
// leaves it untouched.
|
||||
let updated = match &req.merchant_profile_id {
|
||||
Some(Some(profile_id)) => {
|
||||
repo::set_product_merchant_profile(&state.db, &id, Some(profile_id.as_str())).await?
|
||||
}
|
||||
Some(None) => repo::set_product_merchant_profile(&state.db, &id, None).await?,
|
||||
None => updated,
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
|
||||
@@ -14,10 +14,10 @@ use uuid::Uuid;
|
||||
|
||||
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
||||
let q = if only_active {
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE active = 1 ORDER BY name"
|
||||
} else {
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products ORDER BY name"
|
||||
};
|
||||
let rows = sqlx::query(q).fetch_all(pool).await?;
|
||||
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
|
||||
|
||||
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
|
||||
|
||||
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -301,6 +301,41 @@ pub async fn set_product_entitlements_catalog(
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||
}
|
||||
|
||||
/// Attach a product to a merchant profile (migration 0020). Pass
|
||||
/// `Some(profile_id)` to set it, `None` to clear it (the product then
|
||||
/// resolves to the default profile). The target profile is validated to
|
||||
/// exist first so a bad id returns a clean 404 rather than surfacing as
|
||||
/// a raw foreign-key-violation 500.
|
||||
pub async fn set_product_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
) -> AppResult<Product> {
|
||||
if let Some(profile_id) = merchant_profile_id {
|
||||
if get_merchant_profile_by_id(pool, profile_id).await?.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"merchant profile {profile_id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
"UPDATE products SET merchant_profile_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(&now)
|
||||
.bind(product_id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("product {product_id}")));
|
||||
}
|
||||
get_product_by_id(pool, product_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||
}
|
||||
|
||||
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
@@ -326,6 +361,13 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
|
||||
.filter(|v| !v.is_empty());
|
||||
// merchant_profile_id lands in migration 0020. NULL = resolves to
|
||||
// the default profile (back-compat); try_get is tolerant of older
|
||||
// rows / SELECTs that predate the column.
|
||||
let merchant_profile_id: Option<String> = row
|
||||
.try_get::<Option<String>, _>("merchant_profile_id")
|
||||
.ok()
|
||||
.flatten();
|
||||
Ok(Product {
|
||||
id: row.try_get("id")?,
|
||||
slug: row.try_get("slug")?,
|
||||
@@ -337,6 +379,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
active: active_int != 0,
|
||||
metadata,
|
||||
entitlements_catalog,
|
||||
merchant_profile_id,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct Product {
|
||||
/// behavior); operators can opt-in by adding rows.
|
||||
#[serde(default)]
|
||||
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
|
||||
/// Merchant profile this product belongs to (migration 0020). None
|
||||
/// resolves to the default profile (back-compat for rows created
|
||||
/// before the operator ran more than one profile). Set via the admin
|
||||
/// product form when >1 profile exists.
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user