Skip to main content

max / makenotwork

14.9 KB · 510 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 false,
154 )
155 .await?;
156
157 // Apply scheduled publish time if provided
158 let post = if let Some(ref publish_at_str) = req.publish_at {
159 let dt = chrono::DateTime::parse_from_rfc3339(publish_at_str)
160 .map_err(|_| AppError::validation("Invalid publish_at datetime (use ISO 8601 / RFC 3339)".to_string()))?;
161 let dt_utc = dt.with_timezone(&chrono::Utc);
162 if dt_utc <= chrono::Utc::now() {
163 return Err(AppError::validation("publish_at must be in the future".to_string()));
164 }
165 db::blog_posts::update_blog_post(
166 &state.db,
167 post.id,
168 &post.title,
169 &post.slug,
170 &post.body_markdown,
171 &post.body_html,
172 false, // not published yet — scheduler handles it
173 Some(Some(dt_utc)),
174 None,
175 None,
176 )
177 .await?
178 } else {
179 post
180 };
181
182 tracing::info!(user = %req.user_id, post = %post.id, "blog post created via CLI");
183
184 Ok(Json(BlogPostResponse::from_db(&post)))
185 }
186
187 /// DELETE /api/internal/creator/blog/{id}?user_id={uuid}
188 ///
189 /// Delete a blog post.
190 #[tracing::instrument(skip_all, name = "internal::delete_blog_post")]
191 pub(super) async fn delete_blog_post(
192 State(state): State<AppState>,
193 _auth: ServiceAuth,
194 Path(post_id): Path<BlogPostId>,
195 Query(query): Query<ItemUserQuery>,
196 ) -> Result<impl IntoResponse> {
197 let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id)
198 .await?
199 .ok_or(AppError::NotFound)?;
200
201 let project = db::projects::get_project_by_id(&state.db, post.project_id)
202 .await?
203 .ok_or(AppError::NotFound)?;
204 if project.user_id != query.user_id {
205 return Err(AppError::Forbidden);
206 }
207
208 db::blog_posts::delete_blog_post(&state.db, post_id).await?;
209
210 tracing::info!(user = %query.user_id, post = %post_id, "blog post deleted via CLI");
211
212 Ok(axum::http::StatusCode::NO_CONTENT)
213 }
214
215 // ── Promo codes ──
216
217 #[derive(Serialize)]
218 struct PromoCodeResponse {
219 id: PromoCodeId,
220 code: String,
221 code_purpose: CodePurpose,
222 discount_type: Option<DiscountType>,
223 discount_value: Option<i32>,
224 item_title: Option<String>,
225 project_title: Option<String>,
226 max_uses: Option<i32>,
227 use_count: i32,
228 created_at: String,
229 }
230
231 /// GET /api/internal/creator/promo-codes?user_id={uuid}
232 ///
233 /// List all promo codes for a creator.
234 #[tracing::instrument(skip_all, name = "internal::list_promo_codes")]
235 pub(super) async fn list_promo_codes(
236 State(state): State<AppState>,
237 _auth: ServiceAuth,
238 Query(query): Query<UserIdQuery>,
239 ) -> Result<impl IntoResponse> {
240 let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, query.user_id).await?;
241 let data: Vec<PromoCodeResponse> = codes
242 .into_iter()
243 .map(|c| PromoCodeResponse {
244 id: c.id,
245 code: c.code,
246 code_purpose: c.code_purpose,
247 discount_type: c.discount_type,
248 discount_value: c.discount_value,
249 item_title: c.item_title,
250 project_title: c.project_title,
251 max_uses: c.max_uses,
252 use_count: c.use_count,
253 created_at: c.created_at.to_rfc3339(),
254 })
255 .collect();
256
257 Ok(Json(data))
258 }
259
260 #[derive(Deserialize)]
261 pub(super) struct CreatePromoCodeRequest {
262 user_id: UserId,
263 code: String,
264 #[serde(default = "default_code_purpose")]
265 code_purpose: CodePurpose,
266 discount_type: Option<DiscountType>,
267 discount_value: Option<i32>,
268 #[serde(default)]
269 max_uses: Option<i32>,
270 #[serde(default)]
271 item_id: Option<ItemId>,
272 #[serde(default)]
273 project_id: Option<ProjectId>,
274 }
275
276 fn default_code_purpose() -> CodePurpose {
277 CodePurpose::Discount
278 }
279
280 /// POST /api/internal/creator/promo-codes
281 ///
282 /// Create a new promo code.
283 #[tracing::instrument(skip_all, name = "internal::create_promo_code")]
284 pub(super) async fn create_promo_code(
285 State(state): State<AppState>,
286 _auth: ServiceAuth,
287 Json(req): Json<CreatePromoCodeRequest>,
288 ) -> Result<impl IntoResponse> {
289 // Validate code format: 1-50 chars, alphanumeric + hyphens
290 if req.code.is_empty() || req.code.len() > 50 {
291 return Err(AppError::BadRequest("Code must be 1-50 characters".to_string()));
292 }
293 if !req.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
294 return Err(AppError::BadRequest("Code must be alphanumeric (hyphens and underscores allowed)".to_string()));
295 }
296
297 // Verify item ownership if scoped to an item
298 if let Some(item_id) = req.item_id {
299 let owner = db::items::get_item_owner(&state.db, item_id)
300 .await?
301 .ok_or(AppError::NotFound)?;
302 if owner != req.user_id {
303 return Err(AppError::Forbidden);
304 }
305 }
306
307 // Verify project ownership if scoped to a project
308 if let Some(project_id) = req.project_id {
309 let project = db::projects::get_project_by_id(&state.db, project_id)
310 .await?
311 .ok_or(AppError::NotFound)?;
312 if project.user_id != req.user_id {
313 return Err(AppError::Forbidden);
314 }
315 }
316
317 let code = db::promo_codes::create_promo_code(
318 &state.db,
319 req.user_id,
320 &req.code,
321 req.code_purpose,
322 req.discount_type,
323 req.discount_value,
324 0, // min_price_cents
325 None, // trial_days
326 req.max_uses,
327 None, // expires_at
328 None, // starts_at
329 req.item_id,
330 req.project_id,
331 None, // tier_id
332 )
333 .await?;
334
335 tracing::info!(user = %req.user_id, code = %code.code, "promo code created via CLI");
336
337 Ok(Json(PromoCodeResponse {
338 id: code.id,
339 code: code.code,
340 code_purpose: code.code_purpose,
341 discount_type: code.discount_type,
342 discount_value: code.discount_value,
343 item_title: None,
344 project_title: None,
345 max_uses: code.max_uses,
346 use_count: code.use_count,
347 created_at: code.created_at.to_rfc3339(),
348 }))
349 }
350
351 /// DELETE /api/internal/creator/promo-codes/{id}?user_id={uuid}
352 ///
353 /// Delete a promo code.
354 #[tracing::instrument(skip_all, name = "internal::delete_promo_code")]
355 pub(super) async fn delete_promo_code(
356 State(state): State<AppState>,
357 _auth: ServiceAuth,
358 Path(code_id): Path<PromoCodeId>,
359 Query(query): Query<UserIdQuery>,
360 ) -> Result<impl IntoResponse> {
361 let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
362 .await?
363 .ok_or(AppError::NotFound)?;
364 if code.creator_id != query.user_id {
365 return Err(AppError::Forbidden);
366 }
367
368 db::promo_codes::delete_promo_code(&state.db, code_id).await?;
369
370 tracing::info!(user = %query.user_id, code = %code.code, "promo code deleted via CLI");
371
372 Ok(axum::http::StatusCode::NO_CONTENT)
373 }
374
375 // ── License keys ──
376
377 #[derive(Serialize)]
378 struct LicenseKeyResponse {
379 id: LicenseKeyId,
380 key_code: KeyCode,
381 activation_count: i32,
382 max_activations: Option<i32>,
383 is_revoked: bool,
384 created_at: String,
385 }
386
387 /// GET /api/internal/creator/items/{id}/keys?user_id={uuid}
388 ///
389 /// List license keys for an item.
390 #[tracing::instrument(skip_all, name = "internal::list_license_keys")]
391 pub(super) async fn list_license_keys(
392 State(state): State<AppState>,
393 _auth: ServiceAuth,
394 Path(item_id): Path<ItemId>,
395 Query(query): Query<ItemUserQuery>,
396 ) -> Result<impl IntoResponse> {
397 let owner = db::items::get_item_owner(&state.db, item_id)
398 .await?
399 .ok_or(AppError::NotFound)?;
400 if owner != query.user_id {
401 return Err(AppError::Forbidden);
402 }
403
404 let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
405 let data: Vec<LicenseKeyResponse> = keys
406 .into_iter()
407 .map(|k| LicenseKeyResponse {
408 id: k.id,
409 key_code: k.key_code,
410 activation_count: k.activation_count,
411 max_activations: k.max_activations,
412 is_revoked: k.revoked_at.is_some(),
413 created_at: k.created_at.to_rfc3339(),
414 })
415 .collect();
416
417 Ok(Json(data))
418 }
419
420 #[derive(Deserialize)]
421 pub(super) struct GenerateKeyRequest {
422 user_id: UserId,
423 }
424
425 /// POST /api/internal/creator/items/{id}/keys
426 ///
427 /// Generate a new license key for an item.
428 #[tracing::instrument(skip_all, name = "internal::generate_license_key")]
429 pub(super) async fn generate_license_key(
430 State(state): State<AppState>,
431 _auth: ServiceAuth,
432 Path(item_id): Path<ItemId>,
433 Json(req): Json<GenerateKeyRequest>,
434 ) -> Result<impl IntoResponse> {
435 let item = db::items::get_item_by_id(&state.db, item_id)
436 .await?
437 .ok_or(AppError::NotFound)?;
438
439 let project = db::projects::get_project_by_id(&state.db, item.project_id)
440 .await?
441 .ok_or(AppError::NotFound)?;
442 if project.user_id != req.user_id {
443 return Err(AppError::Forbidden);
444 }
445
446 // Enforce cap
447 let count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
448 if count >= 1000 {
449 return Err(AppError::BadRequest("Maximum of 1000 keys per item".to_string()));
450 }
451
452 let key_code = helpers::generate_key_code();
453 let max_activations = item.default_max_activations;
454
455 let key = db::license_keys::create_license_key(
456 &state.db,
457 item_id,
458 req.user_id,
459 None, // transaction_id
460 &key_code,
461 max_activations,
462 )
463 .await?;
464
465 tracing::info!(user = %req.user_id, item = %item_id, "license key generated via CLI");
466
467 Ok(Json(LicenseKeyResponse {
468 id: key.id,
469 key_code: key.key_code,
470 activation_count: key.activation_count,
471 max_activations: key.max_activations,
472 is_revoked: false,
473 created_at: key.created_at.to_rfc3339(),
474 }))
475 }
476
477 #[derive(Deserialize)]
478 pub(super) struct RevokeKeyRequest {
479 user_id: UserId,
480 }
481
482 /// POST /api/internal/creator/keys/{id}/revoke
483 ///
484 /// Revoke a license key.
485 #[tracing::instrument(skip_all, name = "internal::revoke_license_key")]
486 pub(super) async fn revoke_license_key(
487 State(state): State<AppState>,
488 _auth: ServiceAuth,
489 Path(key_id): Path<LicenseKeyId>,
490 Json(req): Json<RevokeKeyRequest>,
491 ) -> Result<impl IntoResponse> {
492 let key = db::license_keys::get_license_key_by_id(&state.db, key_id)
493 .await?
494 .ok_or(AppError::NotFound)?;
495
496 // Verify ownership through item -> project
497 let owner = db::items::get_item_owner(&state.db, key.item_id)
498 .await?
499 .ok_or(AppError::NotFound)?;
500 if owner != req.user_id {
501 return Err(AppError::Forbidden);
502 }
503
504 db::license_keys::revoke_license_key(&state.db, key_id).await?;
505
506 tracing::info!(user = %req.user_id, key = %key_id, "license key revoked via CLI");
507
508 Ok(axum::http::StatusCode::NO_CONTENT)
509 }
510