Skip to main content

max / makenotwork

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