//! Internal content management: blog posts, promo codes, and license keys. use crate::auth::InternalActor; use axum::{ Json, extract::{Path, Query, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::{ auth::ServiceAuth, config::Config, db::{ self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, ProjectId, PromoCodeId, Slug, }, error::{AppError, Result}, helpers, validation, }; // ── Shared query types ── #[derive(Deserialize)] pub(super) struct UserIdQuery {} #[derive(Deserialize)] pub(super) struct ItemUserQuery {} #[derive(Deserialize)] pub(super) struct ProjectUserQuery {} // ── Blog posts ── #[derive(Serialize)] struct BlogPostResponse { id: BlogPostId, title: String, slug: String, is_published: bool, publish_at: Option, created_at: String, updated_at: String, } impl BlogPostResponse { fn from_db(post: &db::DbBlogPost) -> Self { Self { id: post.id, title: post.title.clone(), slug: post.slug.to_string(), is_published: post.published_at.is_some(), publish_at: post.publish_at.map(|d| d.to_rfc3339()), created_at: post.created_at.to_rfc3339(), updated_at: post.updated_at.to_rfc3339(), } } } /// GET /api/internal/creator/projects/{id}/blog?user_id={uuid} /// /// List blog posts for a project. #[tracing::instrument(skip_all, name = "internal::list_blog_posts")] pub(super) async fn list_blog_posts( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(project_id): Path, Query(_query): Query, ) -> Result { let project = db::projects::get_project_by_id(&db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let posts = db::blog_posts::get_blog_posts_by_project(&db, project_id).await?; let data: Vec = posts.iter().map(BlogPostResponse::from_db).collect(); Ok(Json(data)) } #[derive(Deserialize)] pub(super) struct CreateBlogPostRequest { project_id: ProjectId, title: String, #[serde(default)] body_markdown: String, #[serde(default)] publish: bool, /// Optional ISO 8601 datetime for scheduled publishing. /// When set, overrides `publish` (post is created as draft, scheduled for this time). publish_at: Option, } /// POST /api/internal/creator/blog /// /// Create a new blog post in a project. #[tracing::instrument(skip_all, name = "internal::create_blog_post")] pub(super) async fn create_blog_post( State(db): State, State(config): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let project = db::projects::get_project_by_id(&db, req.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } validation::validate_blog_post_title(&req.title)?; if !req.body_markdown.is_empty() { validation::validate_blog_post_body(&req.body_markdown)?; } let base = helpers::slugify(&req.title).to_string(); let cdn_base = config.cdn_base_url.as_str(); let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, actor.user_id(), cdn_base); // If publish_at is set, create as draft and then set the schedule let publish = if req.publish_at.is_some() { false } else { req.publish }; // Auto-suffix on a UNIQUE(project_id, slug) collision and retry. The prior // code pre-checked existence but did NOT retry the insert, so a concurrent // create racing the same slug surfaced a raw 500 (ultra-fuzz Run #1 UX D1). let pool = &db; let (project_id, user_id) = (req.project_id, actor.user_id()); let (title_s, body_md_s, body_html_s) = ( req.title.as_str(), req.body_markdown.as_str(), body_html.as_str(), ); let post = crate::helpers::insert_with_unique_slug(&base, |slug| async move { let slug = Slug::from_trusted(slug); db::blog_posts::create_blog_post( pool, project_id, user_id, title_s, &slug, body_md_s, body_html_s, publish, false, false, ) .await }) .await?; // Apply scheduled publish time if provided let post = if let Some(ref publish_at_str) = req.publish_at { let dt = chrono::DateTime::parse_from_rfc3339(publish_at_str).map_err(|_| { AppError::validation( "Invalid publish_at datetime (use ISO 8601 / RFC 3339)".to_string(), ) })?; let dt_utc = dt.with_timezone(&chrono::Utc); if dt_utc <= chrono::Utc::now() { return Err(AppError::validation( "publish_at must be in the future".to_string(), )); } db::blog_posts::update_blog_post( &db, post.id, &post.title, &post.slug, &post.body_markdown, &post.body_html, Some(false), // not published yet, scheduler handles it Some(Some(dt_utc)), None, None, ) .await? } else { post }; tracing::info!(user = %actor.user_id(), post = %post.id, "blog post created via CLI"); Ok(Json(BlogPostResponse::from_db(&post))) } /// DELETE /api/internal/creator/blog/{id}?user_id={uuid} #[tracing::instrument(skip_all, name = "internal::delete_blog_post")] pub(super) async fn delete_blog_post( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(post_id): Path, Query(_query): Query, ) -> Result { let post = db::blog_posts::get_blog_post_by_id(&db, post_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, post.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } db::blog_posts::delete_blog_post(&db, post_id, actor.user_id()).await?; tracing::info!(user = %actor.user_id(), post = %post_id, "blog post deleted via CLI"); Ok(axum::http::StatusCode::NO_CONTENT) } // ── Promo codes ── #[derive(Serialize)] struct PromoCodeResponse { id: PromoCodeId, code: String, code_purpose: CodePurpose, discount_type: Option, discount_value: Option, item_title: Option, project_title: Option, max_uses: Option, use_count: i32, created_at: String, } /// GET /api/internal/creator/promo-codes?user_id={uuid} /// /// List all promo codes for a creator. #[tracing::instrument(skip_all, name = "internal::list_promo_codes")] pub(super) async fn list_promo_codes( State(db): State, actor: InternalActor, _auth: ServiceAuth, Query(_query): Query, ) -> Result { let codes = db::promo_codes::get_promo_codes_by_creator(&db, actor.user_id()).await?; let data: Vec = codes .into_iter() .map(|c| PromoCodeResponse { id: c.id, code: c.code, code_purpose: c.code_purpose, discount_type: c.discount_type, discount_value: c.discount_value, item_title: c.item_title, project_title: c.project_title, max_uses: c.max_uses, use_count: c.use_count, created_at: c.created_at.to_rfc3339(), }) .collect(); Ok(Json(data)) } #[derive(Deserialize)] pub(super) struct CreatePromoCodeRequest { code: String, #[serde(default = "default_code_purpose")] code_purpose: CodePurpose, discount_type: Option, discount_value: Option, #[serde(default)] max_uses: Option, #[serde(default)] item_id: Option, #[serde(default)] project_id: Option, } fn default_code_purpose() -> CodePurpose { CodePurpose::Discount } /// POST /api/internal/creator/promo-codes /// /// Create a new promo code. #[tracing::instrument(skip_all, name = "internal::create_promo_code")] pub(super) async fn create_promo_code( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { // Validate code format: 1-50 chars, alphanumeric + hyphens if req.code.is_empty() || req.code.len() > 50 { return Err(AppError::BadRequest( "Code must be 1-50 characters".to_string(), )); } if !req .code .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return Err(AppError::BadRequest( "Code must be alphanumeric (hyphens and underscores allowed)".to_string(), )); } // Verify item ownership if scoped to an item if let Some(item_id) = req.item_id { let owner = db::items::get_item_owner(&db, item_id) .await? .ok_or(AppError::NotFound)?; if owner != actor.user_id() { return Err(AppError::Forbidden); } } // Verify project ownership if scoped to a project if let Some(project_id) = req.project_id { let project = db::projects::get_project_by_id(&db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } } let code = db::promo_codes::create_promo_code( &db, actor.user_id(), &req.code, req.code_purpose, req.discount_type, req.discount_value, 0, // min_price_cents None, // trial_days req.max_uses, None, // expires_at None, // starts_at req.item_id, req.project_id, None, // tier_id ) .await?; tracing::info!(user = %actor.user_id(), code = %code.code, "promo code created via CLI"); Ok(Json(PromoCodeResponse { id: code.id, code: code.code, code_purpose: code.code_purpose, discount_type: code.discount_type, discount_value: code.discount_value, item_title: None, project_title: None, max_uses: code.max_uses, use_count: code.use_count, created_at: code.created_at.to_rfc3339(), })) } /// DELETE /api/internal/creator/promo-codes/{id}?user_id={uuid} #[tracing::instrument(skip_all, name = "internal::delete_promo_code")] pub(super) async fn delete_promo_code( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(code_id): Path, Query(_query): Query, ) -> Result { let code = db::promo_codes::get_promo_code_by_id(&db, code_id) .await? .ok_or(AppError::NotFound)?; if code.creator_id != actor.user_id() { return Err(AppError::Forbidden); } db::promo_codes::delete_promo_code(&db, code_id).await?; tracing::info!(user = %actor.user_id(), code = %code.code, "promo code deleted via CLI"); Ok(axum::http::StatusCode::NO_CONTENT) } // ── License keys ── #[derive(Serialize)] struct LicenseKeyResponse { id: LicenseKeyId, key_code: KeyCode, activation_count: i32, max_activations: Option, is_revoked: bool, created_at: String, } /// GET /api/internal/creator/items/{id}/keys?user_id={uuid} /// /// List license keys for an item. #[tracing::instrument(skip_all, name = "internal::list_license_keys")] pub(super) async fn list_license_keys( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(item_id): Path, Query(_query): Query, ) -> Result { let owner = db::items::get_item_owner(&db, item_id) .await? .ok_or(AppError::NotFound)?; if owner != actor.user_id() { return Err(AppError::Forbidden); } let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?; let data: Vec = keys .into_iter() .map(|k| LicenseKeyResponse { id: k.id, key_code: k.key_code, activation_count: k.activation_count, max_activations: k.max_activations, is_revoked: k.revoked_at.is_some(), created_at: k.created_at.to_rfc3339(), }) .collect(); Ok(Json(data)) } #[derive(Deserialize)] pub(super) struct GenerateKeyRequest {} /// POST /api/internal/creator/items/{id}/keys /// /// Generate a new license key for an item. #[tracing::instrument(skip_all, name = "internal::generate_license_key")] pub(super) async fn generate_license_key( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(item_id): Path, Json(_req): Json, ) -> Result { let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } // Enforce cap let count = db::license_keys::count_keys_by_item(&db, item_id).await?; if count >= 1000 { return Err(AppError::BadRequest( "Maximum of 1000 keys per item".to_string(), )); } let key_code = helpers::generate_key_code(); let max_activations = item.default_max_activations; let key = db::license_keys::create_license_key( &db, item_id, actor.user_id(), None, // transaction_id &key_code, max_activations, ) .await?; tracing::info!(user = %actor.user_id(), item = %item_id, "license key generated via CLI"); Ok(Json(LicenseKeyResponse { id: key.id, key_code: key.key_code, activation_count: key.activation_count, max_activations: key.max_activations, is_revoked: false, created_at: key.created_at.to_rfc3339(), })) } #[derive(Deserialize)] pub(super) struct RevokeKeyRequest {} /// POST /api/internal/creator/keys/{id}/revoke #[tracing::instrument(skip_all, name = "internal::revoke_license_key")] pub(super) async fn revoke_license_key( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(key_id): Path, Json(_req): Json, ) -> Result { let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id) .await? .ok_or(AppError::NotFound)?; // Verify ownership through item -> project let owner = db::items::get_item_owner(&db, key.item_id) .await? .ok_or(AppError::NotFound)?; if owner != actor.user_id() { return Err(AppError::Forbidden); } db::license_keys::revoke_license_key(&db, key_id).await?; tracing::info!(user = %actor.user_id(), key = %key_id, "license key revoked via CLI"); Ok(axum::http::StatusCode::NO_CONTENT) }