//! Internal content management: blog posts, promo codes, and license keys. use axum::{ extract::{Path, Query, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::ServiceAuth, db::{ self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, PromoCodeId, ProjectId, Slug, UserId, }, error::{AppError, Result}, helpers, validation, AppState, }; // ── Shared query types ── #[derive(Deserialize)] pub(super) struct UserIdQuery { user_id: UserId, } #[derive(Deserialize)] pub(super) struct ItemUserQuery { user_id: UserId, } #[derive(Deserialize)] pub(super) struct ProjectUserQuery { user_id: UserId, } // ── 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(state): State, _auth: ServiceAuth, Path(project_id): Path, Query(query): Query, ) -> Result { let project = db::projects::get_project_by_id(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } let posts = db::blog_posts::get_blog_posts_by_project(&state.db, project_id).await?; let data: Vec = posts.iter().map(BlogPostResponse::from_db).collect(); Ok(Json(data)) } #[derive(Deserialize)] pub(super) struct CreateBlogPostRequest { user_id: UserId, 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(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let project = db::projects::get_project_by_id(&state.db, req.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.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 mut slug = helpers::slugify(&req.title); if db::blog_posts::blog_post_slug_exists(&state.db, req.project_id, &slug).await? { let base = slug.clone(); let mut counter = 2u32; loop { slug = Slug::from_trusted(format!("{}-{}", base, counter)); if !db::blog_posts::blog_post_slug_exists(&state.db, req.project_id, &slug).await? { break; } counter += 1; } } let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, req.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 }; let post = db::blog_posts::create_blog_post( &state.db, req.project_id, req.user_id, &req.title, &slug, &req.body_markdown, &body_html, publish, false, false, ) .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( &state.db, post.id, &post.title, &post.slug, &post.body_markdown, &post.body_html, false, // not published yet — scheduler handles it Some(Some(dt_utc)), None, None, ) .await? } else { post }; tracing::info!(user = %req.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} /// /// Delete a blog post. #[tracing::instrument(skip_all, name = "internal::delete_blog_post")] pub(super) async fn delete_blog_post( State(state): State, _auth: ServiceAuth, Path(post_id): Path, Query(query): Query, ) -> Result { let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, post.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } db::blog_posts::delete_blog_post(&state.db, post_id).await?; tracing::info!(user = %query.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(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, query.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 { user_id: UserId, 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(state): State, _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(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if owner != req.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(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } } let code = db::promo_codes::create_promo_code( &state.db, req.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 = %req.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} /// /// Delete a promo code. #[tracing::instrument(skip_all, name = "internal::delete_promo_code")] pub(super) async fn delete_promo_code( State(state): State, _auth: ServiceAuth, Path(code_id): Path, Query(query): Query, ) -> Result { let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) .await? .ok_or(AppError::NotFound)?; if code.creator_id != query.user_id { return Err(AppError::Forbidden); } db::promo_codes::delete_promo_code(&state.db, code_id).await?; tracing::info!(user = %query.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(state): State, _auth: ServiceAuth, Path(item_id): Path, Query(query): Query, ) -> Result { let owner = db::items::get_item_owner(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if owner != query.user_id { return Err(AppError::Forbidden); } let keys = db::license_keys::get_license_keys_by_item(&state.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 { user_id: UserId, } /// 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(state): State, _auth: ServiceAuth, Path(item_id): Path, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } // Enforce cap let count = db::license_keys::count_keys_by_item(&state.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( &state.db, item_id, req.user_id, None, // transaction_id &key_code, max_activations, ) .await?; tracing::info!(user = %req.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 { user_id: UserId, } /// POST /api/internal/creator/keys/{id}/revoke /// /// Revoke a license key. #[tracing::instrument(skip_all, name = "internal::revoke_license_key")] pub(super) async fn revoke_license_key( State(state): State, _auth: ServiceAuth, Path(key_id): Path, Json(req): Json, ) -> Result { let key = db::license_keys::get_license_key_by_id(&state.db, key_id) .await? .ok_or(AppError::NotFound)?; // Verify ownership through item -> project let owner = db::items::get_item_owner(&state.db, key.item_id) .await? .ok_or(AppError::NotFound)?; if owner != req.user_id { return Err(AppError::Forbidden); } db::license_keys::revoke_license_key(&state.db, key_id).await?; tracing::info!(user = %req.user_id, key = %key_id, "license key revoked via CLI"); Ok(axum::http::StatusCode::NO_CONTENT) }