Skip to main content

max / makenotwork

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