Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE stack_template DROP COLUMN IF EXISTS price;
ALTER TABLE stack_template DROP COLUMN IF EXISTS billing_cycle;
ALTER TABLE stack_template DROP COLUMN IF EXISTS currency;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Add pricing columns to stack_template
-- Creator sets price during template submission; webhook sends it to User Service products table
ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS price DOUBLE PRECISION DEFAULT 0;
ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(50) DEFAULT 'free';
ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
79 changes: 68 additions & 11 deletions src/connectors/user_service/marketplace_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ pub struct MarketplaceWebhookPayload {
/// Template description
pub description: Option<String>,

/// Price in specified currency (if not free)
/// Price in specified currency (set by creator during submission)
pub price: Option<f64>,

/// Billing cycle: "one_time" or "monthly"/"yearly"
/// Billing cycle: "free", "one_time", or "subscription"
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_cycle: Option<String>,

Expand All @@ -50,7 +50,7 @@ pub struct MarketplaceWebhookPayload {
/// Creator/vendor user ID from Stacker
pub vendor_user_id: Option<String>,

/// Vendor name or email
/// Vendor display name (creator_name from template)
pub vendor_name: Option<String>,

/// Category of template
Expand All @@ -60,6 +60,34 @@ pub struct MarketplaceWebhookPayload {
/// Tags/keywords
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<serde_json::Value>,

/// Full description (long_description from template)
#[serde(skip_serializing_if = "Option::is_none")]
pub long_description: Option<String>,

/// Tech stack metadata (JSON object of services/apps)
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_stack: Option<serde_json::Value>,

/// Creator display name
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_name: Option<String>,

/// Total deployments count
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_count: Option<i32>,

/// Total views count
#[serde(skip_serializing_if = "Option::is_none")]
pub view_count: Option<i32>,

/// When the template was approved
#[serde(skip_serializing_if = "Option::is_none")]
pub approved_at: Option<String>,

/// Minimum plan required to deploy
#[serde(skip_serializing_if = "Option::is_none")]
pub required_plan_name: Option<String>,
}

/// Response from User Service webhook endpoint
Expand Down Expand Up @@ -159,17 +187,28 @@ impl MarketplaceWebhookSender {
.short_description
.clone()
.or_else(|| template.long_description.clone()),
price: None, // Pricing not stored in Stacker (User Service responsibility)
billing_cycle: None,
currency: None,
price: template.price,
billing_cycle: template.billing_cycle.clone(),
currency: template.currency.clone(),
vendor_user_id: Some(vendor_id.to_string()),
vendor_name: Some(vendor_id.to_string()),
vendor_name: template.creator_name.clone(),
category: category_code,
tags: if let serde_json::Value::Array(_) = template.tags {
Some(template.tags.clone())
} else {
None
},
long_description: template.long_description.clone(),
tech_stack: if template.tech_stack != serde_json::json!({}) {
Some(template.tech_stack.clone())
} else {
None
},
creator_name: template.creator_name.clone(),
deploy_count: template.deploy_count,
view_count: template.view_count,
approved_at: template.approved_at.map(|dt| dt.to_rfc3339()),
required_plan_name: template.required_plan_name.clone(),
};

self.send_webhook(&payload).instrument(span).await
Expand Down Expand Up @@ -198,17 +237,28 @@ impl MarketplaceWebhookSender {
.short_description
.clone()
.or_else(|| template.long_description.clone()),
price: None,
billing_cycle: None,
currency: None,
price: template.price,
billing_cycle: template.billing_cycle.clone(),
currency: template.currency.clone(),
vendor_user_id: Some(vendor_id.to_string()),
vendor_name: Some(vendor_id.to_string()),
vendor_name: template.creator_name.clone(),
category: category_code,
tags: if let serde_json::Value::Array(_) = template.tags {
Some(template.tags.clone())
} else {
None
},
long_description: template.long_description.clone(),
tech_stack: if template.tech_stack != serde_json::json!({}) {
Some(template.tech_stack.clone())
} else {
None
},
creator_name: template.creator_name.clone(),
deploy_count: template.deploy_count,
view_count: template.view_count,
approved_at: template.approved_at.map(|dt| dt.to_rfc3339()),
required_plan_name: template.required_plan_name.clone(),
};

self.send_webhook(&payload).instrument(span).await
Expand Down Expand Up @@ -239,6 +289,13 @@ impl MarketplaceWebhookSender {
vendor_name: None,
category: None,
tags: None,
long_description: None,
tech_stack: None,
creator_name: None,
deploy_count: None,
view_count: None,
approved_at: None,
required_plan_name: None,
};

self.send_webhook(&payload).instrument(span).await
Expand Down
50 changes: 44 additions & 6 deletions src/db/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
t.view_count,
t.deploy_count,
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency,
t.created_at,
t.updated_at,
t.approved_at
Expand Down Expand Up @@ -107,6 +110,9 @@
t.view_count,
t.deploy_count,
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency,
t.created_at,
t.updated_at,
t.approved_at
Expand All @@ -131,7 +137,7 @@
) -> Result<(StackTemplate, Option<StackTemplateVersion>), String> {
let query_span = tracing::info_span!("marketplace_get_by_slug_with_latest", slug = %slug);

let template = sqlx::query_as!(

Check failure on line 140 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

type annotations needed

Check failure on line 140 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 140 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

type annotations needed

Check failure on line 140 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
StackTemplate,
r#"SELECT
t.id,
Expand All @@ -150,6 +156,9 @@
t.view_count,
t.deploy_count,
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency,
t.created_at,
t.updated_at,
t.approved_at
Expand Down Expand Up @@ -197,7 +206,7 @@
) -> Result<Option<StackTemplate>, String> {
let query_span = tracing::info_span!("marketplace_get_by_id", id = %template_id);

let template = sqlx::query_as!(

Check failure on line 209 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

type annotations needed

Check failure on line 209 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 209 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

type annotations needed

Check failure on line 209 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
StackTemplate,
r#"SELECT
t.id,
Expand All @@ -218,7 +227,10 @@
t.created_at,
t.updated_at,
t.approved_at,
t.required_plan_name
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency
FROM stack_template t
LEFT JOIN stack_category c ON t.category_id = c.id
WHERE t.id = $1"#,
Expand Down Expand Up @@ -246,16 +258,21 @@
category_code: Option<&str>,
tags: serde_json::Value,
tech_stack: serde_json::Value,
price: f64,
billing_cycle: &str,
currency: &str,
) -> Result<StackTemplate, String> {
let query_span = tracing::info_span!("marketplace_create_draft", slug = %slug);

let price_f64 = price;

let rec = sqlx::query_as!(

Check failure on line 269 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 269 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
StackTemplate,
r#"INSERT INTO stack_template (
creator_user_id, creator_name, name, slug,
short_description, long_description, category_id,
tags, tech_stack, status
) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,'draft')
tags, tech_stack, status, price, billing_cycle, currency
) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,'draft',$10,$11,$12)
RETURNING
id,
creator_user_id,
Expand All @@ -273,6 +290,9 @@
view_count,
deploy_count,
required_plan_name,
price,
billing_cycle,
currency,
created_at,
updated_at,
approved_at
Expand All @@ -285,7 +305,10 @@
long_description,
category_code,
tags,
tech_stack
tech_stack,
price_f64,
billing_cycle,
currency
)
.fetch_one(pool)
.instrument(query_span)
Expand Down Expand Up @@ -370,6 +393,9 @@
category_code: Option<&str>,
tags: Option<serde_json::Value>,
tech_stack: Option<serde_json::Value>,
price: Option<f64>,
billing_cycle: Option<&str>,
currency: Option<&str>,
) -> Result<bool, String> {
let query_span = tracing::info_span!("marketplace_update_metadata", template_id = %template_id);

Expand All @@ -390,22 +416,28 @@
return Err("Template not editable in current status".to_string());
}

let res = sqlx::query!(

Check failure on line 419 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 419 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
r#"UPDATE stack_template SET
name = COALESCE($2, name),
short_description = COALESCE($3, short_description),
long_description = COALESCE($4, long_description),
category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id),
tags = COALESCE($6, tags),
tech_stack = COALESCE($7, tech_stack)
tech_stack = COALESCE($7, tech_stack),
price = COALESCE($8, price),
billing_cycle = COALESCE($9, billing_cycle),
currency = COALESCE($10, currency)
WHERE id = $1::uuid"#,
template_id,
name,
short_description,
long_description,
category_code,
tags,
tech_stack
tech_stack,
price,
billing_cycle,
currency
)
.execute(pool)
.instrument(query_span)
Expand Down Expand Up @@ -519,7 +551,7 @@
pub async fn list_mine(pool: &PgPool, user_id: &str) -> Result<Vec<StackTemplate>, String> {
let query_span = tracing::info_span!("marketplace_list_mine", user = %user_id);

sqlx::query_as!(

Check failure on line 554 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 554 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
StackTemplate,
r#"SELECT
t.id,
Expand All @@ -538,6 +570,9 @@
t.view_count,
t.deploy_count,
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency,
t.created_at,
t.updated_at,
t.approved_at
Expand All @@ -559,7 +594,7 @@
pub async fn admin_list_submitted(pool: &PgPool) -> Result<Vec<StackTemplate>, String> {
let query_span = tracing::info_span!("marketplace_admin_list_submitted");

sqlx::query_as!(

Check failure on line 597 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 597 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
StackTemplate,
r#"SELECT
t.id,
Expand All @@ -578,6 +613,9 @@
t.view_count,
t.deploy_count,
t.required_plan_name,
t.price,
t.billing_cycle,
t.currency,
t.created_at,
t.updated_at,
t.approved_at
Expand Down Expand Up @@ -679,7 +717,7 @@
// Use INSERT ... ON CONFLICT DO UPDATE to upsert
// Handle conflicts on both id and name (both have unique constraints)
let result = sqlx::query(
r#"

Check failure on line 720 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 720 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
INSERT INTO stack_category (id, name, title, metadata)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
Expand All @@ -693,7 +731,7 @@
.bind(&category.title)
.bind(serde_json::json!({"priority": category.priority}))
.execute(pool)
.await;

Check failure on line 734 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 734 in src/db/marketplace.rs

View workflow job for this annotation

GitHub Actions / Cargo and npm build

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

// If conflict on id fails, try conflict on name
let result = match result {
Expand Down
3 changes: 3 additions & 0 deletions src/models/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub struct StackTemplate {
pub view_count: Option<i32>,
pub deploy_count: Option<i32>,
pub required_plan_name: Option<String>,
pub price: Option<f64>,
pub billing_cycle: Option<String>,
pub currency: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub approved_at: Option<DateTime<Utc>>,
Expand Down
23 changes: 23 additions & 0 deletions src/routes/marketplace/creator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ pub struct CreateTemplateRequest {
pub version: Option<String>,
pub stack_definition: Option<serde_json::Value>,
pub definition_format: Option<String>,
/// Pricing: "free", "one_time", or "subscription"
pub plan_type: Option<String>,
/// Price amount (e.g. 9.99). Ignored when plan_type is "free"
pub price: Option<f64>,
/// ISO 4217 currency code, default "USD"
pub currency: Option<String>,
}

#[tracing::instrument(name = "Create draft template")]
Expand All @@ -34,6 +40,11 @@ pub async fn create_handler(

let creator_name = format!("{} {}", user.first_name, user.last_name);

// Normalize pricing: plan_type "free" forces price to 0
let billing_cycle = req.plan_type.unwrap_or_else(|| "free".to_string());
let price = if billing_cycle == "free" { 0.0 } else { req.price.unwrap_or(0.0) };
let currency = req.currency.unwrap_or_else(|| "USD".to_string());

// Check if template with this slug already exists for this user
let existing = db::marketplace::get_by_slug_and_user(pg_pool.get_ref(), &req.slug, &user.id)
.await
Expand All @@ -51,6 +62,9 @@ pub async fn create_handler(
req.category_code.as_deref(),
Some(tags.clone()),
Some(tech_stack.clone()),
Some(price),
Some(billing_cycle.as_str()),
Some(currency.as_str()),
)
.await
.map_err(|err| JsonResponse::<models::StackTemplate>::build().internal_server_error(err))?;
Expand Down Expand Up @@ -83,6 +97,9 @@ pub async fn create_handler(
req.category_code.as_deref(),
tags,
tech_stack,
price,
&billing_cycle,
&currency,
)
.await
.map_err(|err| {
Expand Down Expand Up @@ -121,6 +138,9 @@ pub struct UpdateTemplateRequest {
pub category_code: Option<String>,
pub tags: Option<serde_json::Value>,
pub tech_stack: Option<serde_json::Value>,
pub plan_type: Option<String>,
pub price: Option<f64>,
pub currency: Option<String>,
}

#[tracing::instrument(name = "Update template metadata")]
Expand Down Expand Up @@ -158,6 +178,9 @@ pub async fn update_handler(
req.category_code.as_deref(),
req.tags,
req.tech_stack,
req.price,
req.plan_type.as_deref(),
req.currency.as_deref(),
)
.await
.map_err(|err| JsonResponse::<serde_json::Value>::build().bad_request(err))?;
Expand Down
Loading