Skip to main content

max / makenotwork

12.6 KB · 454 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 // Parse item type
108 let item_type: ItemType = req
109 .item_type
110 .parse()
111 .map_err(|_| AppError::BadRequest(format!("Invalid item type: {}", req.item_type)))?;
112
113 // Verify project ownership
114 let project = db::projects::get_project_by_id(&db, req.project_id)
115 .await?
116 .ok_or(AppError::NotFound)?;
117 if project.user_id != actor.user_id() {
118 return Err(AppError::Forbidden);
119 }
120
121 let ai_tier = req.ai_tier.unwrap_or(AiTier::Handmade);
122 let item = db::items::create_item(
123 &db,
124 req.project_id,
125 &req.title,
126 None,
127 PriceCents::new(req.price_cents)?,
128 item_type,
129 ai_tier,
130 req.ai_disclosure.as_deref(),
131 )
132 .await?;
133
134 tracing::info!(
135 user = %actor.user_id(),
136 item = %item.id,
137 "item created via CLI"
138 );
139
140 Ok(Json(CreateItemResponse {
141 item_id: item.id,
142 project_id: req.project_id,
143 }))
144 }
145
146 // ── Item detail ──
147
148 /// GET /api/internal/creator/items/{id}?user_id={uuid}
149 ///
150 /// Get full item detail. Verifies ownership through the project.
151 #[tracing::instrument(skip_all, name = "internal::get_item")]
152 pub(super) async fn get_item(
153 State(db): State<PgPool>,
154 actor: InternalActor,
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(&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(&db, item.project_id)
165 .await?
166 .ok_or(AppError::NotFound)?;
167 if project.user_id != actor.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 #[serde(default)]
179 title: Option<String>,
180 #[serde(default)]
181 description: Option<String>,
182 #[serde(default)]
183 price_cents: Option<i32>,
184 #[serde(default)]
185 is_public: Option<bool>,
186 #[serde(default)]
187 pwyw_enabled: Option<bool>,
188 #[serde(default)]
189 pwyw_min_cents: Option<i32>,
190 #[serde(default)]
191 ai_tier: Option<AiTier>,
192 #[serde(default)]
193 ai_disclosure: Option<String>,
194 }
195
196 /// PUT /api/internal/creator/items/{id}
197 ///
198 /// Partial update of item fields. Only provided fields are changed.
199 #[tracing::instrument(skip_all, name = "internal::update_item")]
200 pub(super) async fn update_item(
201 State(db): State<PgPool>,
202 actor: InternalActor,
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(&db, item_id)
208 .await?
209 .ok_or(AppError::NotFound)?;
210
211 let project = db::projects::get_project_by_id(&db, item.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 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 &db,
236 item_id,
237 actor.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 = %actor.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(db): State<PgPool>,
265 actor: InternalActor,
266 _auth: ServiceAuth,
267 Path(item_id): Path<ItemId>,
268 Query(_query): Query<ItemUserQuery>,
269 ) -> Result<impl IntoResponse> {
270 let item = db::items::get_item_by_id(&db, item_id)
271 .await?
272 .ok_or(AppError::NotFound)?;
273
274 let project = db::projects::get_project_by_id(&db, item.project_id)
275 .await?
276 .ok_or(AppError::NotFound)?;
277 if project.user_id != actor.user_id() {
278 return Err(AppError::Forbidden);
279 }
280
281 // Decrement storage for any S3 files
282 let file_sizes = db::items::get_item_file_sizes(&db, item_id).await?;
283 let version_size = db::versions::sum_file_sizes_for_item(&db, item_id).await?;
284 let total_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0)
285 + file_sizes.cover_file_size_bytes.unwrap_or(0)
286 + file_sizes.video_file_size_bytes.unwrap_or(0)
287 + version_size;
288 if total_bytes > 0 {
289 db::creator_tiers::decrement_storage_used(&db, actor.user_id(), total_bytes).await?;
290 }
291
292 db::items::delete_item(&db, item_id, actor.user_id()).await?;
293
294 tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI");
295
296 Ok(axum::http::StatusCode::NO_CONTENT)
297 }
298
299 // ── Publish / Unpublish ──
300
301 #[derive(Deserialize)]
302 pub(super) struct PublishRequest {}
303
304 /// POST /api/internal/creator/items/{id}/publish
305 ///
306 /// Set is_public=true on an item.
307 #[tracing::instrument(skip_all, name = "internal::publish_item")]
308 pub(super) async fn publish_item(
309 State(db): State<PgPool>,
310 actor: InternalActor,
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(&db, item_id)
316 .await?
317 .ok_or(AppError::NotFound)?;
318
319 let project = db::projects::get_project_by_id(&db, item.project_id)
320 .await?
321 .ok_or(AppError::NotFound)?;
322 if project.user_id != actor.user_id() {
323 return Err(AppError::Forbidden);
324 }
325
326 let updated = db::items::update_item(
327 &db,
328 item_id,
329 actor.user_id(),
330 None,
331 None,
332 None,
333 None,
334 Some(true), // is_public
335 None,
336 None,
337 None,
338 None,
339 None,
340 None, // ai_tier, ai_disclosure
341 )
342 .await?;
343
344 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
345 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish");
346 }
347 tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI");
348
349 Ok(Json(ItemDetailResponse::from_db(&updated)))
350 }
351
352 /// POST /api/internal/creator/items/{id}/unpublish
353 ///
354 /// Set is_public=false on an item.
355 #[tracing::instrument(skip_all, name = "internal::unpublish_item")]
356 pub(super) async fn unpublish_item(
357 State(db): State<PgPool>,
358 actor: InternalActor,
359 _auth: ServiceAuth,
360 Path(item_id): Path<ItemId>,
361 Json(_req): Json<PublishRequest>,
362 ) -> Result<impl IntoResponse> {
363 let item = db::items::get_item_by_id(&db, item_id)
364 .await?
365 .ok_or(AppError::NotFound)?;
366
367 let project = db::projects::get_project_by_id(&db, item.project_id)
368 .await?
369 .ok_or(AppError::NotFound)?;
370 if project.user_id != actor.user_id() {
371 return Err(AppError::Forbidden);
372 }
373
374 let updated = db::items::update_item(
375 &db,
376 item_id,
377 actor.user_id(),
378 None,
379 None,
380 None,
381 None,
382 Some(false), // is_public
383 None,
384 None,
385 None,
386 None,
387 None,
388 None, // ai_tier, ai_disclosure
389 )
390 .await?;
391
392 if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
393 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish");
394 }
395 tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI");
396
397 Ok(Json(ItemDetailResponse::from_db(&updated)))
398 }
399
400 // ── Item versions ──
401
402 #[derive(Serialize)]
403 struct VersionResponse {
404 id: String,
405 version_number: String,
406 changelog: Option<String>,
407 file_name: Option<String>,
408 file_size_bytes: Option<i64>,
409 download_count: i32,
410 is_current: bool,
411 created_at: String,
412 }
413
414 /// GET /api/internal/creator/items/{id}/versions?user_id={uuid}
415 ///
416 /// List versions for an item (newest first).
417 #[tracing::instrument(skip_all, name = "internal::item_versions")]
418 pub(super) async fn item_versions(
419 State(db): State<PgPool>,
420 actor: InternalActor,
421 _auth: ServiceAuth,
422 Path(item_id): Path<ItemId>,
423 Query(_query): Query<ItemUserQuery>,
424 ) -> Result<impl IntoResponse> {
425 let item = db::items::get_item_by_id(&db, item_id)
426 .await?
427 .ok_or(AppError::NotFound)?;
428
429 let project = db::projects::get_project_by_id(&db, item.project_id)
430 .await?
431 .ok_or(AppError::NotFound)?;
432 if project.user_id != actor.user_id() {
433 return Err(AppError::Forbidden);
434 }
435
436 let versions = db::versions::get_versions_by_item(&db, item_id).await?;
437
438 let data: Vec<VersionResponse> = versions
439 .into_iter()
440 .map(|v| VersionResponse {
441 id: v.id.to_string(),
442 version_number: v.version_number,
443 changelog: v.changelog,
444 file_name: v.file_name,
445 file_size_bytes: v.file_size_bytes,
446 download_count: v.download_count,
447 is_current: v.is_current,
448 created_at: v.created_at.to_rfc3339(),
449 })
450 .collect();
451
452 Ok(Json(data))
453 }
454