Skip to main content

max / makenotwork

13.0 KB · 466 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 // A PWYW minimum's floor is the owner's settlement currency's, and this
222 // route's actor is a service rather than a session, so the currency is not
223 // already in hand. Read it only when a minimum is actually being set.
224 let pwyw_min_cents = match req.pwyw_min_cents {
225 Some(cents) => {
226 let owner = db::users::get_user_by_id(&db, project.user_id)
227 .await?
228 .ok_or(AppError::NotFound)?;
229 Some(PriceCents::pwyw_minimum(cents, owner.settlement_currency)?)
230 }
231 None => None,
232 };
233
234 // Build ai_disclosure double-Option
235 let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier {
236 match ai_tier {
237 AiTier::Assisted => Some(req.ai_disclosure.as_deref()),
238 _ => Some(None),
239 }
240 } else if req.ai_disclosure.is_some() {
241 Some(req.ai_disclosure.as_deref())
242 } else {
243 None
244 };
245
246 let updated = db::items::update_item(
247 &db,
248 item_id,
249 actor.user_id(),
250 req.title.as_deref(),
251 req.description.as_deref(),
252 req.price_cents.map(PriceCents::new).transpose()?,
253 None, // item_type
254 req.is_public,
255 req.pwyw_enabled,
256 pwyw_min_cents,
257 None, // publish_at
258 None, // web_only
259 req.ai_tier,
260 ai_disclosure,
261 )
262 .await?;
263
264 tracing::info!(user = %actor.user_id(), item = %item_id, "item updated via CLI");
265
266 Ok(Json(ItemDetailResponse::from_db(&updated)))
267 }
268
269 // --- Delete item ---
270
271 /// DELETE /api/internal/creator/items/{id}?user_id={uuid}
272 ///
273 /// Permanently delete an item. Verifies ownership.
274 #[tracing::instrument(skip_all, name = "internal::delete_item")]
275 pub(super) async fn delete_item(
276 State(db): State<PgPool>,
277 actor: InternalActor,
278 _auth: ServiceAuth,
279 Path(item_id): Path<ItemId>,
280 Query(_query): Query<ItemUserQuery>,
281 ) -> Result<impl IntoResponse> {
282 let item = db::items::get_item_by_id(&db, item_id)
283 .await?
284 .ok_or(AppError::NotFound)?;
285
286 let project = db::projects::get_project_by_id(&db, item.project_id)
287 .await?
288 .ok_or(AppError::NotFound)?;
289 if project.user_id != actor.user_id() {
290 return Err(AppError::Forbidden);
291 }
292
293 // Decrement storage for any S3 files
294 let file_sizes = db::items::get_item_file_sizes(&db, item_id).await?;
295 let version_size = db::versions::sum_file_sizes_for_item(&db, item_id).await?;
296 let total_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0)
297 + file_sizes.cover_file_size_bytes.unwrap_or(0)
298 + file_sizes.video_file_size_bytes.unwrap_or(0)
299 + version_size;
300 if total_bytes > 0 {
301 db::creator_tiers::decrement_storage_used(&db, actor.user_id(), total_bytes).await?;
302 }
303
304 db::items::delete_item(&db, item_id, actor.user_id()).await?;
305
306 tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI");
307
308 Ok(axum::http::StatusCode::NO_CONTENT)
309 }
310
311 // --- Publish / Unpublish ---
312
313 #[derive(Deserialize)]
314 pub(super) struct PublishRequest {}
315
316 /// POST /api/internal/creator/items/{id}/publish
317 ///
318 /// Set is_public=true on an item.
319 #[tracing::instrument(skip_all, name = "internal::publish_item")]
320 pub(super) async fn publish_item(
321 State(db): State<PgPool>,
322 actor: InternalActor,
323 _auth: ServiceAuth,
324 Path(item_id): Path<ItemId>,
325 Json(_req): Json<PublishRequest>,
326 ) -> Result<impl IntoResponse> {
327 let item = db::items::get_item_by_id(&db, item_id)
328 .await?
329 .ok_or(AppError::NotFound)?;
330
331 let project = db::projects::get_project_by_id(&db, item.project_id)
332 .await?
333 .ok_or(AppError::NotFound)?;
334 if project.user_id != actor.user_id() {
335 return Err(AppError::Forbidden);
336 }
337
338 let updated = db::items::update_item(
339 &db,
340 item_id,
341 actor.user_id(),
342 None,
343 None,
344 None,
345 None,
346 Some(true), // is_public
347 None,
348 None,
349 None,
350 None,
351 None,
352 None, // ai_tier, ai_disclosure
353 )
354 .await?;
355
356 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
357 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish");
358 }
359 tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI");
360
361 Ok(Json(ItemDetailResponse::from_db(&updated)))
362 }
363
364 /// POST /api/internal/creator/items/{id}/unpublish
365 ///
366 /// Set is_public=false on an item.
367 #[tracing::instrument(skip_all, name = "internal::unpublish_item")]
368 pub(super) async fn unpublish_item(
369 State(db): State<PgPool>,
370 actor: InternalActor,
371 _auth: ServiceAuth,
372 Path(item_id): Path<ItemId>,
373 Json(_req): Json<PublishRequest>,
374 ) -> Result<impl IntoResponse> {
375 let item = db::items::get_item_by_id(&db, item_id)
376 .await?
377 .ok_or(AppError::NotFound)?;
378
379 let project = db::projects::get_project_by_id(&db, item.project_id)
380 .await?
381 .ok_or(AppError::NotFound)?;
382 if project.user_id != actor.user_id() {
383 return Err(AppError::Forbidden);
384 }
385
386 let updated = db::items::update_item(
387 &db,
388 item_id,
389 actor.user_id(),
390 None,
391 None,
392 None,
393 None,
394 Some(false), // is_public
395 None,
396 None,
397 None,
398 None,
399 None,
400 None, // ai_tier, ai_disclosure
401 )
402 .await?;
403
404 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
405 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish");
406 }
407 tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI");
408
409 Ok(Json(ItemDetailResponse::from_db(&updated)))
410 }
411
412 // --- Item versions ---
413
414 #[derive(Serialize)]
415 struct VersionResponse {
416 id: String,
417 version_number: String,
418 changelog: Option<String>,
419 file_name: Option<String>,
420 file_size_bytes: Option<i64>,
421 download_count: i32,
422 is_current: bool,
423 created_at: String,
424 }
425
426 /// GET /api/internal/creator/items/{id}/versions?user_id={uuid}
427 ///
428 /// List versions for an item (newest first).
429 #[tracing::instrument(skip_all, name = "internal::item_versions")]
430 pub(super) async fn item_versions(
431 State(db): State<PgPool>,
432 actor: InternalActor,
433 _auth: ServiceAuth,
434 Path(item_id): Path<ItemId>,
435 Query(_query): Query<ItemUserQuery>,
436 ) -> Result<impl IntoResponse> {
437 let item = db::items::get_item_by_id(&db, item_id)
438 .await?
439 .ok_or(AppError::NotFound)?;
440
441 let project = db::projects::get_project_by_id(&db, item.project_id)
442 .await?
443 .ok_or(AppError::NotFound)?;
444 if project.user_id != actor.user_id() {
445 return Err(AppError::Forbidden);
446 }
447
448 let versions = db::versions::get_versions_by_item(&db, item_id).await?;
449
450 let data: Vec<VersionResponse> = versions
451 .into_iter()
452 .map(|v| VersionResponse {
453 id: v.id.to_string(),
454 version_number: v.version_number,
455 changelog: v.changelog,
456 file_name: v.file_name,
457 file_size_bytes: v.file_size_bytes,
458 download_count: v.download_count,
459 is_current: v.is_current,
460 created_at: v.created_at.to_rfc3339(),
461 })
462 .collect();
463
464 Ok(Json(data))
465 }
466