Skip to main content

max / makenotwork

12.5 KB · 453 lines History Blame Raw
1 //! Internal item management: create, update, delete, publish, unpublish, and version history.
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 db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId},
16 error::{AppError, Result},
17 validation,
18 };
19
20 // --- Shared types ---
21
22 #[derive(Serialize)]
23 struct ItemDetailResponse {
24 id: ItemId,
25 title: String,
26 description: Option<String>,
27 price_cents: i32,
28 item_type: ItemType,
29 is_public: bool,
30 slug: String,
31 sort_order: i32,
32 sales_count: i32,
33 download_count: i32,
34 play_count: i32,
35 pwyw_enabled: bool,
36 pwyw_min_cents: Option<i32>,
37 has_audio: bool,
38 has_cover: bool,
39 ai_tier: AiTier,
40 ai_disclosure: Option<String>,
41 created_at: String,
42 updated_at: String,
43 }
44
45 impl ItemDetailResponse {
46 fn from_db(item: &db::DbItem) -> Self {
47 Self {
48 id: item.id,
49 title: item.title.clone(),
50 description: item.description.clone(),
51 price_cents: item.price_cents,
52 item_type: item.item_type,
53 is_public: item.is_public,
54 slug: item.slug.clone(),
55 sort_order: item.sort_order,
56 sales_count: item.sales_count,
57 download_count: item.download_count,
58 play_count: item.play_count,
59 pwyw_enabled: item.pwyw_enabled,
60 pwyw_min_cents: item.pwyw_min_cents,
61 has_audio: item.audio_s3_key.is_some(),
62 has_cover: item.cover_s3_key.is_some(),
63 ai_tier: item.ai_tier,
64 ai_disclosure: item.ai_disclosure.clone(),
65 created_at: item.created_at.to_rfc3339(),
66 updated_at: item.updated_at.to_rfc3339(),
67 }
68 }
69 }
70
71 #[derive(Deserialize)]
72 pub(super) struct ItemUserQuery {}
73
74 // --- Create item (for CLI upload pipeline) ---
75
76 #[derive(Deserialize)]
77 pub(super) struct CreateItemRequest {
78 project_id: ProjectId,
79 title: String,
80 item_type: String,
81 #[serde(default)]
82 price_cents: i32,
83 #[serde(default)]
84 ai_tier: Option<AiTier>,
85 #[serde(default)]
86 ai_disclosure: Option<String>,
87 }
88
89 #[derive(Serialize)]
90 struct CreateItemResponse {
91 item_id: ItemId,
92 project_id: ProjectId,
93 }
94
95 /// POST /api/internal/creator/items
96 ///
97 /// Create a new item in a project. Used by the CLI upload pipeline.
98 #[tracing::instrument(skip_all, name = "internal::create_item")]
99 pub(super) async fn create_item(
100 State(db): State<PgPool>,
101 actor: InternalActor,
102 _auth: ServiceAuth,
103 Json(req): Json<CreateItemRequest>,
104 ) -> Result<impl IntoResponse> {
105 validation::validate_item_title(&req.title)?;
106
107 let item_type: ItemType = req
108 .item_type
109 .parse()
110 .map_err(|_| AppError::BadRequest(format!("Invalid item type: {}", req.item_type)))?;
111
112 // Verify project ownership
113 let project = db::projects::get_project_by_id(&db, req.project_id)
114 .await?
115 .ok_or(AppError::NotFound)?;
116 if project.user_id != actor.user_id() {
117 return Err(AppError::Forbidden);
118 }
119
120 let ai_tier = req.ai_tier.unwrap_or(AiTier::Handmade);
121 let item = db::items::create_item(
122 &db,
123 req.project_id,
124 &req.title,
125 None,
126 PriceCents::new(req.price_cents)?,
127 item_type,
128 ai_tier,
129 req.ai_disclosure.as_deref(),
130 )
131 .await?;
132
133 tracing::info!(
134 user = %actor.user_id(),
135 item = %item.id,
136 "item created via CLI"
137 );
138
139 Ok(Json(CreateItemResponse {
140 item_id: item.id,
141 project_id: req.project_id,
142 }))
143 }
144
145 // --- Item detail ---
146
147 /// GET /api/internal/creator/items/{id}?user_id={uuid}
148 ///
149 /// Get full item detail. Verifies ownership through the project.
150 #[tracing::instrument(skip_all, name = "internal::get_item")]
151 pub(super) async fn get_item(
152 State(db): State<PgPool>,
153 actor: InternalActor,
154 _auth: ServiceAuth,
155 Path(item_id): Path<ItemId>,
156 Query(_query): Query<ItemUserQuery>,
157 ) -> Result<impl IntoResponse> {
158 let item = db::items::get_item_by_id(&db, item_id)
159 .await?
160 .ok_or(AppError::NotFound)?;
161
162 // Verify ownership through project
163 let project = db::projects::get_project_by_id(&db, item.project_id)
164 .await?
165 .ok_or(AppError::NotFound)?;
166 if project.user_id != actor.user_id() {
167 return Err(AppError::Forbidden);
168 }
169
170 Ok(Json(ItemDetailResponse::from_db(&item)))
171 }
172
173 // --- Update item ---
174
175 #[derive(Deserialize)]
176 pub(super) struct UpdateItemRequest {
177 #[serde(default)]
178 title: Option<String>,
179 #[serde(default)]
180 description: Option<String>,
181 #[serde(default)]
182 price_cents: Option<i32>,
183 #[serde(default)]
184 is_public: Option<bool>,
185 #[serde(default)]
186 pwyw_enabled: Option<bool>,
187 #[serde(default)]
188 pwyw_min_cents: Option<i32>,
189 #[serde(default)]
190 ai_tier: Option<AiTier>,
191 #[serde(default)]
192 ai_disclosure: Option<String>,
193 }
194
195 /// PUT /api/internal/creator/items/{id}
196 ///
197 /// Partial update of item fields. Only provided fields are changed.
198 #[tracing::instrument(skip_all, name = "internal::update_item")]
199 pub(super) async fn update_item(
200 State(db): State<PgPool>,
201 actor: InternalActor,
202 _auth: ServiceAuth,
203 Path(item_id): Path<ItemId>,
204 Json(req): Json<UpdateItemRequest>,
205 ) -> Result<impl IntoResponse> {
206 let item = db::items::get_item_by_id(&db, item_id)
207 .await?
208 .ok_or(AppError::NotFound)?;
209
210 let project = db::projects::get_project_by_id(&db, item.project_id)
211 .await?
212 .ok_or(AppError::NotFound)?;
213 if project.user_id != actor.user_id() {
214 return Err(AppError::Forbidden);
215 }
216
217 if let Some(ref title) = req.title {
218 validation::validate_item_title(title)?;
219 }
220
221 // Build ai_disclosure double-Option
222 let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier {
223 match ai_tier {
224 AiTier::Assisted => Some(req.ai_disclosure.as_deref()),
225 _ => Some(None),
226 }
227 } else if req.ai_disclosure.is_some() {
228 Some(req.ai_disclosure.as_deref())
229 } else {
230 None
231 };
232
233 let updated = db::items::update_item(
234 &db,
235 item_id,
236 actor.user_id(),
237 req.title.as_deref(),
238 req.description.as_deref(),
239 req.price_cents.map(PriceCents::new).transpose()?,
240 None, // item_type
241 req.is_public,
242 req.pwyw_enabled,
243 req.pwyw_min_cents.map(PriceCents::new).transpose()?,
244 None, // publish_at
245 None, // web_only
246 req.ai_tier,
247 ai_disclosure,
248 )
249 .await?;
250
251 tracing::info!(user = %actor.user_id(), item = %item_id, "item updated via CLI");
252
253 Ok(Json(ItemDetailResponse::from_db(&updated)))
254 }
255
256 // --- Delete item ---
257
258 /// DELETE /api/internal/creator/items/{id}?user_id={uuid}
259 ///
260 /// Permanently delete an item. Verifies ownership.
261 #[tracing::instrument(skip_all, name = "internal::delete_item")]
262 pub(super) async fn delete_item(
263 State(db): State<PgPool>,
264 actor: InternalActor,
265 _auth: ServiceAuth,
266 Path(item_id): Path<ItemId>,
267 Query(_query): Query<ItemUserQuery>,
268 ) -> Result<impl IntoResponse> {
269 let item = db::items::get_item_by_id(&db, item_id)
270 .await?
271 .ok_or(AppError::NotFound)?;
272
273 let project = db::projects::get_project_by_id(&db, item.project_id)
274 .await?
275 .ok_or(AppError::NotFound)?;
276 if project.user_id != actor.user_id() {
277 return Err(AppError::Forbidden);
278 }
279
280 // Decrement storage for any S3 files
281 let file_sizes = db::items::get_item_file_sizes(&db, item_id).await?;
282 let version_size = db::versions::sum_file_sizes_for_item(&db, item_id).await?;
283 let total_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0)
284 + file_sizes.cover_file_size_bytes.unwrap_or(0)
285 + file_sizes.video_file_size_bytes.unwrap_or(0)
286 + version_size;
287 if total_bytes > 0 {
288 db::creator_tiers::decrement_storage_used(&db, actor.user_id(), total_bytes).await?;
289 }
290
291 db::items::delete_item(&db, item_id, actor.user_id()).await?;
292
293 tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI");
294
295 Ok(axum::http::StatusCode::NO_CONTENT)
296 }
297
298 // --- Publish / Unpublish ---
299
300 #[derive(Deserialize)]
301 pub(super) struct PublishRequest {}
302
303 /// POST /api/internal/creator/items/{id}/publish
304 ///
305 /// Set is_public=true on an item.
306 #[tracing::instrument(skip_all, name = "internal::publish_item")]
307 pub(super) async fn publish_item(
308 State(db): State<PgPool>,
309 actor: InternalActor,
310 _auth: ServiceAuth,
311 Path(item_id): Path<ItemId>,
312 Json(_req): Json<PublishRequest>,
313 ) -> Result<impl IntoResponse> {
314 let item = db::items::get_item_by_id(&db, item_id)
315 .await?
316 .ok_or(AppError::NotFound)?;
317
318 let project = db::projects::get_project_by_id(&db, item.project_id)
319 .await?
320 .ok_or(AppError::NotFound)?;
321 if project.user_id != actor.user_id() {
322 return Err(AppError::Forbidden);
323 }
324
325 let updated = db::items::update_item(
326 &db,
327 item_id,
328 actor.user_id(),
329 None,
330 None,
331 None,
332 None,
333 Some(true), // is_public
334 None,
335 None,
336 None,
337 None,
338 None,
339 None, // ai_tier, ai_disclosure
340 )
341 .await?;
342
343 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
344 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish");
345 }
346 tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI");
347
348 Ok(Json(ItemDetailResponse::from_db(&updated)))
349 }
350
351 /// POST /api/internal/creator/items/{id}/unpublish
352 ///
353 /// Set is_public=false on an item.
354 #[tracing::instrument(skip_all, name = "internal::unpublish_item")]
355 pub(super) async fn unpublish_item(
356 State(db): State<PgPool>,
357 actor: InternalActor,
358 _auth: ServiceAuth,
359 Path(item_id): Path<ItemId>,
360 Json(_req): Json<PublishRequest>,
361 ) -> Result<impl IntoResponse> {
362 let item = db::items::get_item_by_id(&db, item_id)
363 .await?
364 .ok_or(AppError::NotFound)?;
365
366 let project = db::projects::get_project_by_id(&db, item.project_id)
367 .await?
368 .ok_or(AppError::NotFound)?;
369 if project.user_id != actor.user_id() {
370 return Err(AppError::Forbidden);
371 }
372
373 let updated = db::items::update_item(
374 &db,
375 item_id,
376 actor.user_id(),
377 None,
378 None,
379 None,
380 None,
381 Some(false), // is_public
382 None,
383 None,
384 None,
385 None,
386 None,
387 None, // ai_tier, ai_disclosure
388 )
389 .await?;
390
391 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
392 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish");
393 }
394 tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI");
395
396 Ok(Json(ItemDetailResponse::from_db(&updated)))
397 }
398
399 // --- Item versions ---
400
401 #[derive(Serialize)]
402 struct VersionResponse {
403 id: String,
404 version_number: String,
405 changelog: Option<String>,
406 file_name: Option<String>,
407 file_size_bytes: Option<i64>,
408 download_count: i32,
409 is_current: bool,
410 created_at: String,
411 }
412
413 /// GET /api/internal/creator/items/{id}/versions?user_id={uuid}
414 ///
415 /// List versions for an item (newest first).
416 #[tracing::instrument(skip_all, name = "internal::item_versions")]
417 pub(super) async fn item_versions(
418 State(db): State<PgPool>,
419 actor: InternalActor,
420 _auth: ServiceAuth,
421 Path(item_id): Path<ItemId>,
422 Query(_query): Query<ItemUserQuery>,
423 ) -> Result<impl IntoResponse> {
424 let item = db::items::get_item_by_id(&db, item_id)
425 .await?
426 .ok_or(AppError::NotFound)?;
427
428 let project = db::projects::get_project_by_id(&db, item.project_id)
429 .await?
430 .ok_or(AppError::NotFound)?;
431 if project.user_id != actor.user_id() {
432 return Err(AppError::Forbidden);
433 }
434
435 let versions = db::versions::get_versions_by_item(&db, item_id).await?;
436
437 let data: Vec<VersionResponse> = versions
438 .into_iter()
439 .map(|v| VersionResponse {
440 id: v.id.to_string(),
441 version_number: v.version_number,
442 changelog: v.changelog,
443 file_name: v.file_name,
444 file_size_bytes: v.file_size_bytes,
445 download_count: v.download_count,
446 is_current: v.is_current,
447 created_at: v.created_at.to_rfc3339(),
448 })
449 .collect();
450
451 Ok(Json(data))
452 }
453