Skip to main content

max / makenotwork

14.9 KB · 508 lines History Blame Raw
1 //! Internal content management: blog posts, promo codes, and license keys.
2
3 use axum::{
4 extract::{Path, Query, State},
5 response::IntoResponse,
6 Json,
7 };
8 use serde::{Deserialize, Serialize};
9
10 use crate::{
11 auth::ServiceAuth,
12 db::{
13 self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, PromoCodeId,
14 ProjectId, Slug, UserId,
15 },
16 error::{AppError, Result},
17 helpers,
18 validation,
19 AppState,
20 };
21
22 // ── Shared query types ──
23
24 #[derive(Deserialize)]
25 pub(super) struct UserIdQuery {
26 user_id: UserId,
27 }
28
29 #[derive(Deserialize)]
30 pub(super) struct ItemUserQuery {
31 user_id: UserId,
32 }
33
34 #[derive(Deserialize)]
35 pub(super) struct ProjectUserQuery {
36 user_id: UserId,
37 }
38
39 // ── Blog posts ──
40
41 #[derive(Serialize)]
42 struct BlogPostResponse {
43 id: BlogPostId,
44 title: String,
45 slug: String,
46 is_published: bool,
47 publish_at: Option<String>,
48 created_at: String,
49 updated_at: String,
50 }
51
52 impl BlogPostResponse {
53 fn from_db(post: &db::DbBlogPost) -> Self {
54 Self {
55 id: post.id,
56 title: post.title.clone(),
57 slug: post.slug.to_string(),
58 is_published: post.published_at.is_some(),
59 publish_at: post.publish_at.map(|d| d.to_rfc3339()),
60 created_at: post.created_at.to_rfc3339(),
61 updated_at: post.updated_at.to_rfc3339(),
62 }
63 }
64 }
65
66 /// GET /api/internal/creator/projects/{id}/blog?user_id={uuid}
67 ///
68 /// List blog posts for a project.
69 #[tracing::instrument(skip_all, name = "internal::list_blog_posts")]
70 pub(super) async fn list_blog_posts(
71 State(state): State<AppState>,
72 _auth: ServiceAuth,
73 Path(project_id): Path<ProjectId>,
74 Query(query): Query<ProjectUserQuery>,
75 ) -> Result<impl IntoResponse> {
76 let project = db::projects::get_project_by_id(&state.db, project_id)
77 .await?
78 .ok_or(AppError::NotFound)?;
79 if project.user_id != query.user_id {
80 return Err(AppError::Forbidden);
81 }
82
83 let posts = db::blog_posts::get_blog_posts_by_project(&state.db, project_id).await?;
84 let data: Vec<BlogPostResponse> = posts.iter().map(BlogPostResponse::from_db).collect();
85
86 Ok(Json(data))
87 }
88
89 #[derive(Deserialize)]
90 pub(super) struct CreateBlogPostRequest {
91 user_id: UserId,
92 project_id: ProjectId,
93 title: String,
94 #[serde(default)]
95 body_markdown: String,
96 #[serde(default)]
97 publish: bool,
98 /// Optional ISO 8601 datetime for scheduled publishing.
99 /// When set, overrides `publish` (post is created as draft, scheduled for this time).
100 publish_at: Option<String>,
101 }
102
103 /// POST /api/internal/creator/blog
104 ///
105 /// Create a new blog post in a project.
106 #[tracing::instrument(skip_all, name = "internal::create_blog_post")]
107 pub(super) async fn create_blog_post(
108 State(state): State<AppState>,
109 _auth: ServiceAuth,
110 Json(req): Json<CreateBlogPostRequest>,
111 ) -> Result<impl IntoResponse> {
112 let project = db::projects::get_project_by_id(&state.db, req.project_id)
113 .await?
114 .ok_or(AppError::NotFound)?;
115 if project.user_id != req.user_id {
116 return Err(AppError::Forbidden);
117 }
118
119 validation::validate_blog_post_title(&req.title)?;
120 if !req.body_markdown.is_empty() {
121 validation::validate_blog_post_body(&req.body_markdown)?;
122 }
123
124 let mut slug = helpers::slugify(&req.title);
125 if db::blog_posts::blog_post_slug_exists(&state.db, req.project_id, &slug).await? {
126 let base = slug.clone();
127 let mut counter = 2u32;
128 loop {
129 slug = Slug::from_trusted(format!("{}-{}", base, counter));
130 if !db::blog_posts::blog_post_slug_exists(&state.db, req.project_id, &slug).await? {
131 break;
132 }
133 counter += 1;
134 }
135 }
136
137 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
138 let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, req.user_id, cdn_base);
139
140 // If publish_at is set, create as draft and then set the schedule
141 let publish = if req.publish_at.is_some() { false } else { req.publish };
142
143 let post = db::blog_posts::create_blog_post(
144 &state.db,
145 req.project_id,
146 req.user_id,
147 &req.title,
148 &slug,
149 &req.body_markdown,
150 &body_html,
151 publish,
152 false,
153 )
154 .await?;
155
156 // Apply scheduled publish time if provided
157 let post = if let Some(ref publish_at_str) = req.publish_at {
158 let dt = chrono::DateTime::parse_from_rfc3339(publish_at_str)
159 .map_err(|_| AppError::validation("Invalid publish_at datetime (use ISO 8601 / RFC 3339)".to_string()))?;
160 let dt_utc = dt.with_timezone(&chrono::Utc);
161 if dt_utc <= chrono::Utc::now() {
162 return Err(AppError::validation("publish_at must be in the future".to_string()));
163 }
164 db::blog_posts::update_blog_post(
165 &state.db,
166 post.id,
167 &post.title,
168 &post.slug,
169 &post.body_markdown,
170 &post.body_html,
171 false, // not published yet — scheduler handles it
172 Some(Some(dt_utc)),
173 None,
174 )
175 .await?
176 } else {
177 post
178 };
179
180 tracing::info!(user = %req.user_id, post = %post.id, "blog post created via CLI");
181
182 Ok(Json(BlogPostResponse::from_db(&post)))
183 }
184
185 /// DELETE /api/internal/creator/blog/{id}?user_id={uuid}
186 ///
187 /// Delete a blog post.
188 #[tracing::instrument(skip_all, name = "internal::delete_blog_post")]
189 pub(super) async fn delete_blog_post(
190 State(state): State<AppState>,
191 _auth: ServiceAuth,
192 Path(post_id): Path<BlogPostId>,
193 Query(query): Query<ItemUserQuery>,
194 ) -> Result<impl IntoResponse> {
195 let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id)
196 .await?
197 .ok_or(AppError::NotFound)?;
198
199 let project = db::projects::get_project_by_id(&state.db, post.project_id)
200 .await?
201 .ok_or(AppError::NotFound)?;
202 if project.user_id != query.user_id {
203 return Err(AppError::Forbidden);
204 }
205
206 db::blog_posts::delete_blog_post(&state.db, post_id).await?;
207
208 tracing::info!(user = %query.user_id, post = %post_id, "blog post deleted via CLI");
209
210 Ok(axum::http::StatusCode::NO_CONTENT)
211 }
212
213 // ── Promo codes ──
214
215 #[derive(Serialize)]
216 struct PromoCodeResponse {
217 id: PromoCodeId,
218 code: String,
219 code_purpose: CodePurpose,
220 discount_type: Option<DiscountType>,
221 discount_value: Option<i32>,
222 item_title: Option<String>,
223 project_title: Option<String>,
224 max_uses: Option<i32>,
225 use_count: i32,
226 created_at: String,
227 }
228
229 /// GET /api/internal/creator/promo-codes?user_id={uuid}
230 ///
231 /// List all promo codes for a creator.
232 #[tracing::instrument(skip_all, name = "internal::list_promo_codes")]
233 pub(super) async fn list_promo_codes(
234 State(state): State<AppState>,
235 _auth: ServiceAuth,
236 Query(query): Query<UserIdQuery>,
237 ) -> Result<impl IntoResponse> {
238 let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, query.user_id).await?;
239 let data: Vec<PromoCodeResponse> = codes
240 .into_iter()
241 .map(|c| PromoCodeResponse {
242 id: c.id,
243 code: c.code,
244 code_purpose: c.code_purpose,
245 discount_type: c.discount_type,
246 discount_value: c.discount_value,
247 item_title: c.item_title,
248 project_title: c.project_title,
249 max_uses: c.max_uses,
250 use_count: c.use_count,
251 created_at: c.created_at.to_rfc3339(),
252 })
253 .collect();
254
255 Ok(Json(data))
256 }
257
258 #[derive(Deserialize)]
259 pub(super) struct CreatePromoCodeRequest {
260 user_id: UserId,
261 code: String,
262 #[serde(default = "default_code_purpose")]
263 code_purpose: CodePurpose,
264 discount_type: Option<DiscountType>,
265 discount_value: Option<i32>,
266 #[serde(default)]
267 max_uses: Option<i32>,
268 #[serde(default)]
269 item_id: Option<ItemId>,
270 #[serde(default)]
271 project_id: Option<ProjectId>,
272 }
273
274 fn default_code_purpose() -> CodePurpose {
275 CodePurpose::Discount
276 }
277
278 /// POST /api/internal/creator/promo-codes
279 ///
280 /// Create a new promo code.
281 #[tracing::instrument(skip_all, name = "internal::create_promo_code")]
282 pub(super) async fn create_promo_code(
283 State(state): State<AppState>,
284 _auth: ServiceAuth,
285 Json(req): Json<CreatePromoCodeRequest>,
286 ) -> Result<impl IntoResponse> {
287 // Validate code format: 1-50 chars, alphanumeric + hyphens
288 if req.code.is_empty() || req.code.len() > 50 {
289 return Err(AppError::BadRequest("Code must be 1-50 characters".to_string()));
290 }
291 if !req.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
292 return Err(AppError::BadRequest("Code must be alphanumeric (hyphens and underscores allowed)".to_string()));
293 }
294
295 // Verify item ownership if scoped to an item
296 if let Some(item_id) = req.item_id {
297 let owner = db::items::get_item_owner(&state.db, item_id)
298 .await?
299 .ok_or(AppError::NotFound)?;
300 if owner != req.user_id {
301 return Err(AppError::Forbidden);
302 }
303 }
304
305 // Verify project ownership if scoped to a project
306 if let Some(project_id) = req.project_id {
307 let project = db::projects::get_project_by_id(&state.db, project_id)
308 .await?
309 .ok_or(AppError::NotFound)?;
310 if project.user_id != req.user_id {
311 return Err(AppError::Forbidden);
312 }
313 }
314
315 let code = db::promo_codes::create_promo_code(
316 &state.db,
317 req.user_id,
318 &req.code,
319 req.code_purpose,
320 req.discount_type,
321 req.discount_value,
322 0, // min_price_cents
323 None, // trial_days
324 req.max_uses,
325 None, // expires_at
326 None, // starts_at
327 req.item_id,
328 req.project_id,
329 None, // tier_id
330 )
331 .await?;
332
333 tracing::info!(user = %req.user_id, code = %code.code, "promo code created via CLI");
334
335 Ok(Json(PromoCodeResponse {
336 id: code.id,
337 code: code.code,
338 code_purpose: code.code_purpose,
339 discount_type: code.discount_type,
340 discount_value: code.discount_value,
341 item_title: None,
342 project_title: None,
343 max_uses: code.max_uses,
344 use_count: code.use_count,
345 created_at: code.created_at.to_rfc3339(),
346 }))
347 }
348
349 /// DELETE /api/internal/creator/promo-codes/{id}?user_id={uuid}
350 ///
351 /// Delete a promo code.
352 #[tracing::instrument(skip_all, name = "internal::delete_promo_code")]
353 pub(super) async fn delete_promo_code(
354 State(state): State<AppState>,
355 _auth: ServiceAuth,
356 Path(code_id): Path<PromoCodeId>,
357 Query(query): Query<UserIdQuery>,
358 ) -> Result<impl IntoResponse> {
359 let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
360 .await?
361 .ok_or(AppError::NotFound)?;
362 if code.creator_id != query.user_id {
363 return Err(AppError::Forbidden);
364 }
365
366 db::promo_codes::delete_promo_code(&state.db, code_id).await?;
367
368 tracing::info!(user = %query.user_id, code = %code.code, "promo code deleted via CLI");
369
370 Ok(axum::http::StatusCode::NO_CONTENT)
371 }
372
373 // ── License keys ──
374
375 #[derive(Serialize)]
376 struct LicenseKeyResponse {
377 id: LicenseKeyId,
378 key_code: KeyCode,
379 activation_count: i32,
380 max_activations: Option<i32>,
381 is_revoked: bool,
382 created_at: String,
383 }
384
385 /// GET /api/internal/creator/items/{id}/keys?user_id={uuid}
386 ///
387 /// List license keys for an item.
388 #[tracing::instrument(skip_all, name = "internal::list_license_keys")]
389 pub(super) async fn list_license_keys(
390 State(state): State<AppState>,
391 _auth: ServiceAuth,
392 Path(item_id): Path<ItemId>,
393 Query(query): Query<ItemUserQuery>,
394 ) -> Result<impl IntoResponse> {
395 let owner = db::items::get_item_owner(&state.db, item_id)
396 .await?
397 .ok_or(AppError::NotFound)?;
398 if owner != query.user_id {
399 return Err(AppError::Forbidden);
400 }
401
402 let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
403 let data: Vec<LicenseKeyResponse> = keys
404 .into_iter()
405 .map(|k| LicenseKeyResponse {
406 id: k.id,
407 key_code: k.key_code,
408 activation_count: k.activation_count,
409 max_activations: k.max_activations,
410 is_revoked: k.revoked_at.is_some(),
411 created_at: k.created_at.to_rfc3339(),
412 })
413 .collect();
414
415 Ok(Json(data))
416 }
417
418 #[derive(Deserialize)]
419 pub(super) struct GenerateKeyRequest {
420 user_id: UserId,
421 }
422
423 /// POST /api/internal/creator/items/{id}/keys
424 ///
425 /// Generate a new license key for an item.
426 #[tracing::instrument(skip_all, name = "internal::generate_license_key")]
427 pub(super) async fn generate_license_key(
428 State(state): State<AppState>,
429 _auth: ServiceAuth,
430 Path(item_id): Path<ItemId>,
431 Json(req): Json<GenerateKeyRequest>,
432 ) -> Result<impl IntoResponse> {
433 let item = db::items::get_item_by_id(&state.db, item_id)
434 .await?
435 .ok_or(AppError::NotFound)?;
436
437 let project = db::projects::get_project_by_id(&state.db, item.project_id)
438 .await?
439 .ok_or(AppError::NotFound)?;
440 if project.user_id != req.user_id {
441 return Err(AppError::Forbidden);
442 }
443
444 // Enforce cap
445 let count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
446 if count >= 1000 {
447 return Err(AppError::BadRequest("Maximum of 1000 keys per item".to_string()));
448 }
449
450 let key_code = helpers::generate_key_code();
451 let max_activations = item.default_max_activations;
452
453 let key = db::license_keys::create_license_key(
454 &state.db,
455 item_id,
456 req.user_id,
457 None, // transaction_id
458 &key_code,
459 max_activations,
460 )
461 .await?;
462
463 tracing::info!(user = %req.user_id, item = %item_id, "license key generated via CLI");
464
465 Ok(Json(LicenseKeyResponse {
466 id: key.id,
467 key_code: key.key_code,
468 activation_count: key.activation_count,
469 max_activations: key.max_activations,
470 is_revoked: false,
471 created_at: key.created_at.to_rfc3339(),
472 }))
473 }
474
475 #[derive(Deserialize)]
476 pub(super) struct RevokeKeyRequest {
477 user_id: UserId,
478 }
479
480 /// POST /api/internal/creator/keys/{id}/revoke
481 ///
482 /// Revoke a license key.
483 #[tracing::instrument(skip_all, name = "internal::revoke_license_key")]
484 pub(super) async fn revoke_license_key(
485 State(state): State<AppState>,
486 _auth: ServiceAuth,
487 Path(key_id): Path<LicenseKeyId>,
488 Json(req): Json<RevokeKeyRequest>,
489 ) -> Result<impl IntoResponse> {
490 let key = db::license_keys::get_license_key_by_id(&state.db, key_id)
491 .await?
492 .ok_or(AppError::NotFound)?;
493
494 // Verify ownership through item -> project
495 let owner = db::items::get_item_owner(&state.db, key.item_id)
496 .await?
497 .ok_or(AppError::NotFound)?;
498 if owner != req.user_id {
499 return Err(AppError::Forbidden);
500 }
501
502 db::license_keys::revoke_license_key(&state.db, key_id).await?;
503
504 tracing::info!(user = %req.user_id, key = %key_id, "license key revoked via CLI");
505
506 Ok(axum::http::StatusCode::NO_CONTENT)
507 }
508